Files
Felipe 34cbd4a861 feat: implement Google OAuth support for Android and iOS
- Added support for Google OAuth with separate client IDs for Android and iOS.
- Updated `verify_google_id_token` to validate `aud` against both client IDs and check `email_verified`.
- Modified `google_oauth_handler` to accept and process the new client IDs.
- Enhanced security by enforcing explicit JWT algorithm validation.
- Updated mobile app to handle Google OAuth flow using `expo-auth-session`.
- Fixed API request to send `id_token` in snake_case as expected by the backend.
- Added necessary environment variables for Google client IDs in mobile app.
- Implemented intent filter for Google OAuth redirect in AndroidManifest.xml.
2026-03-19 15:24:08 -03:00

46 KiB
Raw Permalink Blame History

MeowSpool Mobile — Agent Guide

Visão Geral

Aplicação mobile do MeowSpool construída com React Native + Expo (SDK ~54), usando expo-router para navegação baseada em sistema de arquivos. A arquitetura espelha o backend Rust: Clean Code / Hexagonal, com camadas bem delimitadas do domínio até a apresentação.

O app é offline-first: todos os dados são persistidos localmente em SQLite (expo-sqlite + SQLCipher) e sincronizados com o backend Rust em background usando estratégia last-write-wins via campo updatedAt.

Bare Workflow: o projeto usa expo prebuild (diretório android/ comitado). Qualquer novo pacote com módulo nativo exige npx expo prebuild --platform android + rebuild completo do APK.


Stack

Componente Biblioteca Observação
Framework expo ~54 Managed Workflow
Navigation expo-router ~4 File-system routing
Linguagem TypeScript 5.x strict mode
Node Version 22 LTS Gerenciado via mise (.mise.toml)
Java Version 17 Obrigatório para React Native 0.81 (Java 24 quebra CMake)
UI React Native + @expo/vector-icons Ionicons
Forms react-hook-form + zod Validação em runtime
Estado global zustand Stores em src/store/
HTTP axios Interceptor Bearer + refresh automático
DB local expo-sqlite + SQLCipher WAL mode, foreign keys
Auth persistência expo-secure-store JWT cifrado no keychain
Safe Area react-native-safe-area-context
Gesture Handler react-native-gesture-handler
Animations react-native-reanimated
QR Code render react-native-qrcode-svg Render de QR Code em tela
SVG inline react-native-svg SvgXml para renderizar SVG como string; sem transformer
Câmera / Scanner expo-camera ~17 ML Kit barcode scan; requer development build
NFC react-native-nfc-manager Leitura e gravação NDEF (NTAG215); requer build nativo
File system expo-file-system/legacy Salvar arquivos no cache; usar import /legacy
Compartilhamento expo-sharing Sheet nativo de compartilhamento de arquivos (iOS)
Abrir arquivo expo-intent-launcher ACTION_VIEW no Android — abre apps de impressão (Niimbot)

Arquitetura de Camadas

src/
├── domain/          ← Entidades puras (sem dependências externas)
├── ports/           ← Interfaces/contratos (traits equivalentes ao Rust)
├── application/     ← Use Cases (orquestram domínio + ports)
├── adapters/
│   ├── remote/      ← Implementações HTTP (Axios → API Rust)
│   ├── local/       ← Implementações SQLite (expo-sqlite)
│   └── nfc/         ← Implementação NFC (react-native-nfc-manager)
├── store/           ← Estado em memória (Zustand) — cache das queries
├── shared/          ← Design tokens, constantes, utilitários
└── presentation/
    └── components/  ← Componentes UI reutilizáveis

Regra de dependência

presentation → store → application → ports ← adapters
                                  ↑
                               domain

Nenhuma camada interna importa de camadas externas. Os adapters implementam os ports.


Estrutura de Arquivos

mobile/
├── .mise.toml                            ← node 22 LTS, java 17
├── app.json                              ← Expo config, scheme "meowspool"
├── babel.config.js                       ← module-resolver + reanimated
├── tsconfig.json                         ← aliases @domain, @ports, @application,
│                                           @adapters, @presentation, @store, @shared
├── index.js                              ← expo-router entry point
├── package.json
│
├── app/                                  ← expo-router file-system routes
│   ├── _layout.tsx                       ← Root layout (carrega sessão)
│   ├── (auth)/
│   │   ├── _layout.tsx                   ← Redireciona se já autenticado
│   │   ├── login.tsx                     ← G3-0
│   │   ├── register.tsx                  ← IX-0
│   │   ├── forgot-password.tsx           ← L2-0
│   │   ├── check-email.tsx               ← RU-0  Tela informativa pós-cadastro ("Confirme seu e-mail")
│   │   ├── reset-password.tsx            ← 117-0
│   │   └── password-reset-done.tsx       ← 135-0
│   ├── filament/
│   │   └── [id].tsx                      ← Deep link handler: redireciona meowspool://filament/{id} → /(app)/inventory/{id}
│   ├── verify-email.tsx                  ← Deep link handler: meowspool://verify-email?token=xxx → chama API e exibe resultado
│   └── (app)/
│       ├── _layout.tsx                   ← Stack autenticado
│       ├── (tabs)/
│       │   ├── _layout.tsx               ← Bottom tabs: Início|Estoque|[+]|Config|Perfil
│       │   ├── home.tsx                  ← 1-0   Dashboard
│       │   ├── inventory.tsx             ← CW-0  Inventário
│       │   ├── add.tsx                   ← FAB → redireciona para /inventory/new
│       │   ├── config.tsx                ← M4-0  Presets de Carretéis
│       │   └── profile.tsx               ← 1LQ-0 Perfil
│       ├── inventory/
│       │   ├── new.tsx                   ← 2X-0  Cadastro de Filamento
│       │   ├── filters.tsx               ← 17H-0 Filtros (bottom sheet)
│       │   ├── [id].tsx                  ← 6L-0  Detalhe do Filamento
│       │   └── [id]/
│       │       └── edit.tsx              ← 13O-0 Editar Filamento
│       ├── config/
│       │   └── presets/
│       │       ├── new.tsx               ← QM-0  Novo Preset
│       │       └── [id]/
│       │           └── edit.tsx          ← 1KD-0 Editar Preset
│       ├── scanner.tsx                   ← Scanner de QR Code (expo-camera ML Kit)
│       ├── nfc-reader.tsx                ← 1RY-0 Bottom sheet modal de leitura NFC
│       └── filaments/
│           └── [id]/
│               ├── qrcode.tsx            ← Ver QR Code (react-native-qrcode-svg)
│               ├── label.tsx             ← Exportar Etiqueta (PDF ou SVG)
│               └── write-nfc.tsx         ← Gravar tag NTAG215 com deep link do filamento
│
└── src/
    ├── domain/
    │   ├── Filament.ts                   ← interface Filament, calcNetWeight
    │   ├── SpoolPreset.ts                ← interface SpoolPreset, isUserOwnedPreset
    │   ├── User.ts                       ← interface User, AuthSession, DTOs
    │   └── Dashboard.ts                  ← DashboardData, MaterialSummary, etc.
    ├── ports/
    │   ├── FilamentRepository.ts         ← interface IFilamentRepository
    │   ├── SpoolPresetRepository.ts      ← interface ISpoolPresetRepository
    │   ├── AuthRepository.ts             ← interface IAuthRepository
    │   └── INFCRepository.ts             ← interface INFCRepository (isSupported, readTag, writeTag, cancelSession)
    ├── application/
    │   ├── filament/
    │   │   ├── CreateFilamentUseCase.ts
    │   │   ├── UpdateFilamentUseCase.ts
    │   │   ├── ListFilamentsUseCase.ts
    │   │   └── DeleteFilamentUseCase.ts
    │   ├── preset/
    │   │   └── PresetUseCases.ts         ← List, Create, Update, Delete
    │   ├── auth/
    │   │   └── AuthUseCases.ts           ← Login, Register, Google, Logout, ForgotPassword, ResendVerification, VerifyEmail, ResetPassword
    │   └── nfc/
    │       ├── ReadNFCTagUseCase.ts      ← lê URI, valida schema meowspool://, extrai filament ID
    │       └── WriteNFCTagUseCase.ts     ← monta meowspool://filament/{id} e grava na tag
    ├── adapters/
    │   ├── remote/
    │   │   ├── httpClient.ts             ← Axios + interceptors JWT + refresh
    │   │   ├── ApiAuthRepository.ts      ← /api/v1/auth/*
    │   │   ├── ApiFilamentRepository.ts  ← /api/v1/filaments/*
    │   │   └── ApiSpoolPresetRepository.ts ← /api/v1/spool-presets/*
    │   ├── local/
    │   │   ├── database.ts               ← init SQLite, WAL, foreign keys, tabelas
    │   │   ├── LocalFilamentRepository.ts
    │   │   └── LocalSpoolPresetRepository.ts
    │   └── nfc/
    │       └── NFCManagerRepository.ts   ← implementa INFCRepository via react-native-nfc-manager
    ├── store/
    │   ├── authStore.ts                  ← Zustand: sessão, SecureStore
    │   ├── filamentStore.ts              ← Zustand: lista + filtros em memória
    │   └── presetStore.ts                ← Zustand: systemPresets + userPresets
    ├── shared/
    │   ├── theme.ts                      ← Design tokens (cores, tipografia, espaçamento)
    │   ├── constants.ts                  ← API_BASE_URL, SecureStore keys, MATERIALS
    │   └── utils/
    │       └── filament.ts               ← calcFilamentPercentage, formatWeight, etc.
    └── presentation/
        └── components/
            ├── ui/
            │   ├── Button.tsx            ← primary, secondary, ghost, danger
            │   ├── Input.tsx             ← label, leftIcon, rightLabel, isPassword, error
            │   ├── Card.tsx              ← container surface com borda
            │   └── Badge.tsx             ← badge dinâmico de estoque
            ├── layout/
            │   ├── Screen.tsx            ← SafeArea + scroll + keyboardAvoiding
            │   └── Header.tsx            ← título centralizado, back, slot direito
            └── filament/
                ├── StockBar.tsx          ← barra de progresso + badge numérico
                ├── ColorSwatch.tsx       ← quadrado/círculo com cor hex
                └── FilamentCard.tsx      ← card de lista

Design Tokens

Arquivo: src/shared/theme.ts

Paleta (siamês)

Token Valor Uso
bgBase #1E1B18 Fundo geral
bgSurface #2A2622 Cards, containers
bgHover #332F2B Hover/pressed em cards
textPrimary #F5EEDC Títulos, peso líquido
textSecondary #C9C1B0 Descrições, labels
accent #38BCC2 Botões de ação, elementos ativos
accentMuted #38BCC226 Background de badges accent (10%)
stockLow #FF6B6B ≤ 15% — vermelho
stockMedium #FF9F43 ≤ 35% — laranja
stockOk #38BCC2 > 35% — accent
border #3D3830 Bordas sutis
error #FF6B6B Mensagens de erro

Tipografia

  • UI: Inter
  • Mono (slugs, hex, URLs): JetBrains Mono
  • App é dark-mode exclusivo — nenhum suporte a light mode

Navegação (expo-router)

Estrutura de grupos

(auth)  ← sem autenticação; redireciona para (app) se sessão válida
(app)   ← com autenticação; redireciona para (auth) se sem sessão
  (tabs) ← bottom tabs fixos
  • Scheme: meowspool:// (configurado em app.json como scheme: "meowspool")
  • Filamento: meowspool://filament/<id>/(app)/inventory/<id>
    • Handler: app/filament/[id].tsx com <Redirect> do expo-router
    • Mesmo link usado em QR Code, NFC e links compartilhados
  • Verificação de e-mail: meowspool://verify-email?token=<token>
    • Handler: app/verify-email.tsx — chama VerifyEmailUseCase.execute(token) automaticamente
    • Exibe estados: carregando → sucesso → erro (token inválido/expirado)
    • Redireciona para /(auth)/login ao concluir
  • Reset de senha: meowspool://reset-password?token=<token>
    • Handler: app/(auth)/reset-password.tsx — lê token via useLocalSearchParams
  • O scanner (scanner.tsx) faz match via /meowspool:\/\/filament\/([^/]+)/ e navega para /(app)/inventory/<id>
  • As tags NFC NTAG215 gravam a URI meowspool://filament/<id> como NDEF URI record

Links de e-mail: o backend gera links https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx (aceitos por clientes de e-mail). O backend redireciona (302) para meowspool://verify-email?token=xxx. O OS reconhece o scheme e abre o app.

  • APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1
  • APP_SCHEME=meowspool

Estratégia Offline-First

  1. Escrita: toda mutação persiste primeiro no SQLite (synced = 0) e enfileira em sync_queue.
  2. Leitura: lê sempre do SQLite; a API é usada apenas para sync.
  3. Sync: background job consome sync_queue e envia para o backend Rust.
  4. Conflito: last-write-wins via updatedAt (ISO 8601). O backend é a fonte de verdade em conflitos.
  5. Auth offline: token armazenado no expo-secure-store; refresh automático via interceptor Axios.

Schema SQLite

-- filaments
id TEXT PRIMARY KEY, user_id TEXT, material TEXT, brand TEXT, model TEXT,
color_hex TEXT, spool_preset_id TEXT, total_weight_g REAL, net_weight_g REAL,
temp_hotend_c REAL, temp_bed_c REAL, flow_factor_pct REAL, notes TEXT,
synced INTEGER DEFAULT 0, updated_at TEXT, created_at TEXT

-- spool_presets
id TEXT PRIMARY KEY, user_id TEXT, name TEXT, spool_weight_g REAL,
is_system INTEGER DEFAULT 0, synced INTEGER DEFAULT 0, created_at TEXT

-- sync_queue
id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT, entity_id TEXT,
operation TEXT, payload TEXT, created_at TEXT

Convenções de Código

Nomenclatura

  • Componentes React: PascalCase (FilamentCard.tsx)
  • Hooks: camelCase com prefixo use (useFilamentStore)
  • Use Cases: PascalCase com sufixo UseCase (CreateFilamentUseCase)
  • Repositórios: PascalCase com prefixo de implementação (ApiFilamentRepository, LocalFilamentRepository)
  • Stores: camelCase (filamentStore.ts), exportados como useXxxStore

Imports

Sempre usar aliases em vez de caminhos relativos:

import { Filament } from "@domain/Filament";
import { useFilamentStore } from "@store/filamentStore";
import { colors } from "@shared/theme";
import { Button } from "@presentation/components/ui/Button";

Componentes de tela

  • Toda tela usa SafeAreaView com backgroundColor: colors.bgBase
  • Header manual (não usa o header do expo-router): headerShown: false
  • CTA fixo: View com padding: spacing[5] + borderTopWidth: 1 + borderTopColor: colors.border
  • ScrollViews com showsVerticalScrollIndicator={false} e keyboardShouldPersistTaps="handled"

Formulários

  • react-hook-form + zodResolver para todos os formulários
  • Validações no schema Zod (nunca inline)
  • isLoading local no componente durante submit
  • Erros de API exibidos via Alert.alert

API do Backend (rotas relevantes)

Base: EXPO_PUBLIC_API_URL (padrão: http://localhost:3000/api/v1)

Auth

Método Rota Descrição
POST /auth/login Login email/senha
POST /auth/register Cadastro
POST /auth/google OAuth Google
POST /auth/refresh Refresh token
POST /auth/logout Logout
POST /auth/resend-verification Reenviar e-mail de verificação
POST /auth/forgot-password Solicitar reset de senha
POST /auth/reset-password Confirmar reset com token
POST /auth/verify-email Verificar e-mail com código

Filaments

Método Rota Descrição
GET /filaments Listar (com filtros)
POST /filaments Criar
GET /filaments/:id Detalhe
PATCH /filaments/:id Atualizar
DELETE /filaments/:id Excluir
GET /filaments/:id/qrcode QR Code PNG
GET /filaments/:id/label.svg Etiqueta SVG (width_mm, height_mm, fields, dpi)
GET /filaments/:id/label.pdf Etiqueta PDF (width_mm, height_mm, fields, dpi)

Spool Presets

Método Rota Descrição
GET /spool-presets Listar (sistema + usuário)
POST /spool-presets Criar preset do usuário
PATCH /spool-presets/:id Atualizar preset do usuário
DELETE /spool-presets/:id Excluir preset do usuário

Dashboard

Método Rota Descrição
GET /dashboard Dados agregados do dashboard

Próximos Passos (integrações pendentes)

  • Instanciar e injetar repositórios concretos nos Use Cases (DI container simples ou Context)
  • Implementar sync background com sync_queue SQLite → API
  • Integrar react-native-qrcode-svg para render real do QR Code
  • Scanner de QR Code com expo-camera v17 (ML Kit) → scanner.tsx
  • Exportar etiqueta SVG via GET /filaments/:id/label.svg
  • Exportar etiqueta PDF via GET /filaments/:id/label.pdf
  • Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta
  • Suporte a Niimbot D11/D110 (22×14mm) com seleção automática de conteúdo mínimo
  • NFC leitura NTAG215 → nfc-reader.tsx (bottom sheet modal)
  • NFC gravação NTAG215 → filaments/[id]/write-nfc.tsx
  • NFC suporte iOS (requer entitlement com.apple.developer.nfc.readwrite)
  • Expo Notifications para alertas de estoque baixo
  • Deep link verify-email?token= com handler e integração ao backend
  • Google OAuth com expo-auth-session (Android + iOS, Google.useAuthRequest + promptAsync)
  • Testes de integração com Jest + Testing Library

Mudanças Recentes (19/03/2026) — segunda entrada

Google OAuth — implementação completa

Arquivos modificados: app/(auth)/login.tsx, src/infrastructure/container.ts, src/adapters/remote/ApiAuthRepository.ts

Pacotes instalados: expo-auth-session, expo-web-browser

Fluxo implementado

Usuário toca "Entrar com Google"
  → promptAsync() abre browser via expo-web-browser
  → Google OAuth consent screen
  → redirect de volta ao app (scheme meowspool://)
  → useEffect detecta response.type === 'success'
  → authentication.idToken enviado para GoogleLoginUseCase
  → POST /auth/oauth/google { id_token }
  → backend valida aud, email_verified, cria/encontra usuário
  → retorna JWT pair → setSession → navega para home

Detalhes técnicos

login.tsx — hook Google.useAuthRequest + useEffect no response:

WebBrowser.maybeCompleteAuthSession(); // fora do componente

const [_request, response, promptAsync] = Google.useAuthRequest({
  androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID,
  iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS,
});

useEffect(() => {
  if (response?.type === 'success') {
    const idToken = response.authentication?.idToken;
    googleLoginUseCase.execute({ idToken })
      .then(session => setSession(session))
      .then(() => router.replace('/(app)/(tabs)/home'))
      .finally(() => setIsGoogleLoading(false));
  }
}, [response]);
  • onGoogleLogin agora é síncrono e apenas chama promptAsync()
  • Tratamento dos três estados: success, error, dismiss

ApiAuthRepository.ts — bug corrigido: o campo era enviado como idToken (camelCase) mas o backend espera id_token (snake_case):

// Antes (quebrado):
await httpClient.post('/auth/oauth/google', input); // enviava { idToken }

// Depois (correto):
await httpClient.post('/auth/oauth/google', { id_token: input.idToken });

container.ts — adicionado export de googleLoginUseCase:

import { GoogleLoginUseCase } from '@application/auth/AuthUseCases';
export const googleLoginUseCase = new GoogleLoginUseCase(authRepository);

Variáveis de ambiente necessárias (mobile/.env)

EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID=724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com
EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS=724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com

Configuração no Google Cloud Console (obrigatória)

O redirect URI do expo-auth-session usa o scheme do app. Adicionar nos OAuth 2.0 Clients:

  • Android (client GOOGLE_CLIENT_ID_ANDROID): SHA-1 fingerprint da keystore + package name com.meowspool.app
  • iOS (client GOOGLE_CLIENT_ID_IOS): bundle ID com.meowspool.app

Nota: o expo-auth-session com Google.useAuthRequest funciona em development build (não no Expo Go). Requer mise exec -- npx expo run:android após instalar os pacotes.


Mudanças Recentes (19/03/2026)

Hardening de segurança — bypass do interceptor de refresh

Arquivo modificado: src/store/authStore.ts

Problema: loadStoredSession usava axios.get diretamente com o access token lido do SecureStore, bypassando o interceptor de refresh do httpClient. Consequência: se o access token expirava, a sessão era destruída mesmo quando o refresh token ainda era válido, forçando login desnecessário.

Correção:

// Antes (bypass do interceptor):
const { data } = await axios.get(`${API_BASE_URL}/users/me`, {
  headers: { Authorization: `Bearer ${accessToken}` },
});

// Depois (usa httpClient → interceptor de refresh ativo):
const { data } = await httpClient.get('/users/me');
// Lê tokens após possível refresh (interceptor pode ter renovado o SecureStore)
const [accessToken, refreshToken] = await Promise.all([
  SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN),
  SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN),
]);

Melhorias adicionais no catch:

const isAuthError =
  axios.isAxiosError(err) &&
  (err.response?.status === 401 || err.response?.status === 403);
if (isAuthError || !axios.isAxiosError(err)) {
  await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
  await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
}
set({ isLoading: false });
  • Erros de rede (sem conexão) não forçam logout — tokens são preservados para quando a conectividade voltar
  • Somente erros 401/403 limpam a sessão
  • Import API_BASE_URL removido (não mais necessário)
  • Import httpClient adicionado de @adapters/remote/httpClient

Mudanças Recentes (14/03/2026)

Reenvio de e-mail de verificação

Arquivos modificados: src/ports/AuthRepository.ts, src/adapters/remote/ApiAuthRepository.ts, src/application/auth/AuthUseCases.ts, src/infrastructure/container.ts, app/(auth)/check-email.tsx, app/(auth)/register.tsx, app/(auth)/login.tsx

Fluxo:

  • register.tsx e login.tsx passam o email como query param: /(auth)/check-email?email=xxx
  • check-email.tsxemail via useLocalSearchParams e exibe botão "Reenviar e-mail"
  • Cooldown de 60s após cada envio para evitar spam
  • Feedback visual: "E-mail reenviado! Verifique sua caixa de entrada."
  • Botão só aparece se email estiver disponível nos params

Cadeia adicionada:

  • AuthRepository.resendVerification(email) — novo método no port
  • ApiAuthRepository.resendVerificationPOST /auth/resend-verification
  • ResendVerificationUseCase — valida email não vazio e delega ao repo
  • resendVerificationUseCase exportado do container.ts

Verificação de e-mail obrigatória antes do login

Arquivos modificados: src/ports/AuthRepository.ts, src/adapters/remote/ApiAuthRepository.ts, src/application/auth/AuthUseCases.ts, app/(auth)/register.tsx, app/(auth)/login.tsx, app/_layout.tsx

Renomeação: app/(auth)/verify-email.tsxapp/(auth)/check-email.tsx

  • Motivo: no expo-router, grupos (auth) são transparentes na URL. app/(auth)/verify-email.tsx e app/verify-email.tsx competiam pelo mesmo path /verify-email, fazendo o deep link cair na tela informativa em vez do handler de verificação.

Mudanças:

  • RegisterUseCase.execute / ApiAuthRepository.register / AuthRepository.register — retornam void (backend não emite mais tokens no registro)
  • register.tsx — remove setSession, navega para /(auth)/check-email após cadastro (usuário fica não autenticado)
  • login.tsx — detecta EMAIL_NOT_VERIFIED (403 do backend) e exibe alerta com link para /(auth)/check-email:
    if (axios.isAxiosError(err) && err.response?.data?.code === 'EMAIL_NOT_VERIFIED') {
      Alert.alert('E-mail não verificado', '...', [
        { text: 'OK', onPress: () => router.push('/(auth)/check-email') },
      ]);
    }
    
  • app/_layout.tsx — auth guard agora exclui rotas públicas da raiz do redirect para login:
    const inPublicRoute = segments[0] === 'verify-email' || segments[0] === 'filament';
    // ...
    } else if (!isAuthenticated && !inAuthGroup && !inPublicRoute) {
      router.replace('/(auth)/login');
    }
    
    Sem esse fix, o guard redirecionava verify-email para login antes de o usuário ver a tela de sucesso.

Rotas públicas da raiz (não sofrem redirect do auth guard):

Rota Arquivo Função
/verify-email app/verify-email.tsx Handler deep link — verifica e-mail e exibe sucesso/erro
/filament app/filament/[id].tsx Handler deep link — redireciona para detalhe do filamento

Contexto: clientes de e-mail (Gmail) bloqueiam links com scheme customizado (meowspool://). O link não abria o app.

Solução implementada no backend: o e-mail agora contém link https://. O backend (GET /api/v1/verify-email?token=xxx) redireciona 302 → meowspool://verify-email?token=xxx. O OS reconhece o scheme e abre o app normalmente.

Impacto no mobile: nenhuma alteração necessária no app — app/verify-email.tsx continua recebendo o deep link e chamando o backend via POST como antes.


Arquivos criados/modificados: app/verify-email.tsx, app/_layout.tsx, src/application/auth/AuthUseCases.ts, src/infrastructure/container.ts

Fluxo

  1. Backend envia e-mail com link https://meowspool.felipecncloud.com/api/v1/verify-email?token=<token> após o registro
  2. Usuário toca no link → browser abre → backend redireciona 302 → meowspool://verify-email?token=<token>
  3. OS reconhece o scheme → expo-router roteia para app/verify-email.tsx
  4. A tela lê token via useLocalSearchParams e chama VerifyEmailUseCase.execute(token) em useEffect
  5. Exibe estado loading durante a chamada, sucesso ou erro (token inválido / expirado / já usado)
  6. Botão redireciona para /(auth)/login

VerifyEmailUseCase

Adicionado em src/application/auth/AuthUseCases.ts:

export class VerifyEmailUseCase {
  constructor(private readonly authRepo: AuthRepository) {}

  async execute(token: string): Promise<void> {
    if (!token) throw new Error('Token inválido.');
    await this.authRepo.verifyEmail(token);
  }
}
  • ApiAuthRepository.verifyEmail(token) já existia (POST /auth/verify-email com { token })
  • verifyEmailUseCase exportado do container.ts
  • Rota verify-email registrada no Stack do app/_layout.tsx

Criada rota app/filament/[id].tsx para tratar deep links abertos externamente (QR Code, NFC, mensagem compartilhada). Sem essa rota, o expo-router exibia "Unmatched Route" ao abrir meowspool://filament/{id} fora do app.

import { Redirect, useLocalSearchParams } from 'expo-router';

export default function FilamentDeepLink() {
  const { id } = useLocalSearchParams<{ id: string }>();
  return <Redirect href={`/(app)/inventory/${id}` as never} />;
}

O redirecionamento leva para /(app)/inventory/[id] (tela de detalhe), que já lida com usuário não autenticado via o guard do _layout.tsx raiz.


Suporte a Impressoras Pequenas — Niimbot D11/D110 (label.tsx)

Arquivo alterado: app/(app)/filaments/[id]/label.tsx

Preset Niimbot 22×14mm

Adicionado como primeira opção em LABEL_SIZES:

{ id: '22x14', label: '22 × 14', sub: 'Niimbot D11/D110' }

O tipo LabelSize foi atualizado para incluir '22x14'.

Seleção automática de conteúdo mínimo

A função handleSizeChange substitui o setSelectedSize direto nos chips. Ao selecionar 22x14, o conteúdo ativo é automaticamente reduzido para { color, name, qrcode } — os únicos campos que cabem fisicamente em 22×14mm. O usuário pode ajustar manualmente após.

const MINI_LABEL_CONTENT: Set<ContentOption> = new Set(['color', 'name', 'qrcode']);

function handleSizeChange(size: LabelSize): void {
  setSelectedSize(size);
  if (size === '22x14') setEnabledContent(new Set(MINI_LABEL_CONTENT));
}

dpi=203 enviado para o backend

O parâmetro dpi é incluído automaticamente nos requests de /label.pdf e /label.svg quando Niimbot está selecionado. Instrui o backend a calcular pixels do SVG na resolução correta da impressora térmica.

Fix: btoa chunking para PDFs grandes

O código original usava String.fromCharCode(...new Uint8Array(buffer)) com spread, que causa stack overflow em buffers > ~64KB em React Native. Substituído por loop em chunks de 8192 bytes:

const bytes = new Uint8Array(response.data);
let binary = '';
const CHUNK = 8192;
for (let i = 0; i < bytes.length; i += CHUNK) {
  binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
const base64 = btoa(binary);

Essa correção se aplica a todos os tamanhos de etiqueta, não apenas Niimbot.


NFC Read/Write — NTAG215

Implementação completa de leitura e gravação NFC para tags NTAG215. O conteúdo gravado é idêntico ao QR Code: meowspool://filament/{id} como NDEF URI record.

Pacote instalado: react-native-nfc-manager

Arquitetura (mesma camada hexagonal do projeto):

Camada Arquivo Responsabilidade
Port src/ports/INFCRepository.ts Interface: isSupported, readTag, writeTag, cancelSession
Adapter src/adapters/nfc/NFCManagerRepository.ts Implementação via react-native-nfc-manager
Use Case src/application/nfc/ReadNFCTagUseCase.ts Lê URI, valida schema meowspool://filament/, extrai ID
Use Case src/application/nfc/WriteNFCTagUseCase.ts Monta meowspool://filament/{id} e delega ao adapter
DI src/infrastructure/container.ts Exporta nfcRepository, readNFCTagUseCase, writeNFCTagUseCase

Telas:

  • app/(app)/nfc-reader.tsx (artboard 1RY-0): bottom sheet modal com animação de ondas pulsantes (3 anéis Animated), badge "LENDO...", título/subtítulo e botão Cancelar. Apresentado como presentation: 'transparentModal' via _layout.tsx. Ao detectar a tag navega diretamente para /(app)/inventory/<id>.
  • app/(app)/filaments/[id]/write-nfc.tsx: tela de gravação com 3 estados visuais (escrevendo / sucesso / erro), mesma animação de ondas. Botão "Gravar NFC" aparece no card de Identificação do Detalhe do Filamento, ao lado de "Ver QR".

Ponto de entrada — Home (home.tsx): botão NFC adicionado à esquerda do botão QR no header. Checa isSupported() antes de abrir o modal; exibe Alert se NFC não estiver disponível.

Ícone NFC: renderizado via SvgXml do react-native-svg (sem SVG transformer). O template string com os 4 arcos SVG é definido no topo de cada arquivo que usa o ícone — não há wrapper de componente.

Inicialização do módulo nativo:

NfcManager.start() deve ser chamado antes de requestTechnology. O adapter usa uma flag started e chama start() de forma lazy (apenas em readTag/writeTag). isSupported() não chama start() — faz apenas um check do módulo nativo via NativeModules.NfcManager != null + try/catch.

// Padrão correto:
async isSupported(): Promise<boolean> {
  if (!this.nativeModuleAvailable) return false;
  try { return await NfcManager.isSupported(); } catch { return false; }
}

// start() só é chamado quando vai usar NFC de verdade:
private async ensureStarted(): Promise<void> {
  if (!this.nativeModuleAvailable) throw new Error('Módulo NFC não disponível.');
  if (!this.started) { await NfcManager.start(); this.started = true; }
}

⚠️ Requer build nativo: react-native-nfc-manager usa módulo nativo. Não funciona no Expo Go. Após instalar ou rodar expo prebuild, é obrigatório recompilar o APK:

mise exec -- npx expo run:android

Permissões Android (adicionadas automaticamente pelo plugin no app.json):

<uses-permission android:name="android.permission.NFC"/>

iOS: não implementado nesta fase. Requer entitlement com.apple.developer.nfc.readwrite da Apple Developer Program. A arquitetura suporta adição futura sem mudanças nas camadas acima do adapter.


Exportação de Etiqueta PDF e SVG (label.tsx)

  • Pacotes instalados: expo-file-system, expo-sharing, react-native-worklets
  • Atenção: importar expo-file-system como expo-file-system/legacy — a API padrão do SDK 54 não exporta EncodingType nem writeAsStringAsync diretamente
  • Fluxo PDF: GET /label.pdf → resposta como arraybufferbtoa para Base64 → salvar com FileSystem.writeAsStringAsync (encoding Base64) → Android: IntentLauncher.startActivityAsync('android.intent.action.VIEW', ...) com content:// URI via FileSystem.getContentUriAsync | iOS: Sharing.shareAsync
  • Fluxo SVG: GET /label.svg → resposta como texto → salvar com encoding 'utf8' → mesmo padrão Android/iOS acima
  • Por que ACTION_VIEW no Android: expo-sharing usa ACTION_SEND, que não é declarado pelo Niimbot (e outros apps de impressão). Esses apps declaram apenas ACTION_VIEW. Usar IntentLauncher com ACTION_VIEW + content:// URI mostra o mesmo seletor que o Gmail usa ao abrir um PDF.
  • Seletor de formato: chips PDF/SVG no footer; padrão é PDF
  • Campos selecionáveis: enabledContent (Set) é convertido para string CSV e enviado como fields query param — o backend respeita a seleção

Implementações Completadas

1. Delete Filament — Integração com API

  • Arquivos: app/(app)/inventory/[id].tsx, app/(app)/inventory/[id]/edit.tsx
  • Mudança: handleDelete() agora chama deleteFilamentUseCase.execute() antes de remover do store local
  • Código:
    const handleDelete = async () => {
      try {
        const user = useAuthStore.getState().user;
        await deleteFilamentUseCase.execute(filament!.id, user?.id);
        removeFilament(filament!.id);
        Alert.alert("Sucesso", "Filamento deletado da API");
      } catch (error) {
        Alert.alert("Erro", "Falha ao deletar: " + (error as Error).message);
      }
    };
    
  • Impacto: Delete agora funciona corretamente em ambos os screens (detail e edit)

2. Pull-to-Refresh — Inventory List

  • Arquivo: app/(tabs)/inventory.tsx

  • Mudança: Adicionado RefreshControl ao FlatList com chamada a listFilamentsUseCase.execute()

  • Código:

    const [isRefreshing, setIsRefreshing] = useState(false);
    
    const onRefresh = async () => {
      setIsRefreshing(true);
      try {
        const user = useAuthStore.getState().user;
        const result = await listFilamentsUseCase.execute(user?.id);
        setFilaments(result);
      } finally {
        setIsRefreshing(false);
      }
    };
    
    <FlatList
      refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
      // ... resto do componente
    />
    
  • Impacto: Usuários podem puxar para baixo e sincronizar lista sem reabrir o app

3. Update Filament — Integração com API

  • Arquivo: app/(app)/inventory/[id]/edit.tsx

  • Mudança: onSubmit() agora chama updateFilamentUseCase.execute() em vez de apenas atualizar o store local

  • Código:

    const onSubmit = async () => {
      try {
        const user = useAuthStore.getState().user;
        const result = await updateFilamentUseCase.execute(
          {
            id: filament!.id,
            brand: formData.brand,
            model: formData.model,
            color: formData.color,
            weight_g: parseFloat(formData.weight_g),
          },
          user?.id,
        );
    
        updateFilament(result);
        Alert.alert("Sucesso", "Filamento atualizado na API");
        router.back();
      } catch (error) {
        Alert.alert("Erro", "Falha ao atualizar: " + (error as Error).message);
      }
    };
    
  • Impacto: Edições de filamento agora persistem no servidor

4. Custom Color Picker — Hex Input com Validação

  • Arquivos: app/(app)/inventory/new.tsx, app/(app)/inventory/[id]/edit.tsx

  • Mudança: Adicionado handleOpenColorPicker() que usa Alert.prompt() para capturar hexadecimais

  • Validação:

    const isValidHex = (hex: string): boolean => {
      return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex);
    };
    
    const normalizeHex = (hex: string): string => {
      return hex.startsWith("#") ? hex : `#${hex}`;
    };
    
    const handleOpenColorPicker = () => {
      setIsOpeningColorPicker(true);
      Alert.prompt(
        "Hex Color",
        "Enter hex color (e.g., #FF5733 or FF5733)",
        [
          {
            text: "Cancel",
            onPress: () => setIsOpeningColorPicker(false),
            style: "cancel",
          },
          {
            text: "Add Color",
            onPress: (input: string | undefined) => {
              if (input && isValidHex(input)) {
                setFormData((prev) => ({ ...prev, color: normalizeHex(input) }));
              } else {
                Alert.alert("Invalid hex color", "Please enter a valid hex code");
              }
              setIsOpeningColorPicker(false);
            },
          },
        ],
        "plain-text",
      );
    };
    
  • Integração: Botão "+" vinculado a handleOpenColorPicker

  • Impacto: Usuários podem agora inserir cores customizadas por código hexadecimal

5. Sincronização de Estado — API-First Pattern

  • Padrão: Todas operações agora seguem: API call → store update
  • Benefício: Source of truth centralizada no servidor
  • Implementação:
    • Delete: deleteFilamentUseCaseremoveFilament()
    • Update: updateFilamentUseCaseupdateFilament(result)
    • Refresh: listFilamentsUseCasesetFilaments(result)
    • Create: createFilamentUseCaseaddFilament(result)

6. CatIcon com Fundo Turquesa — Design Integration

  • Arquivos: src/presentation/components/icons/CatIcon.tsx, app/(auth)/login.tsx

  • Mudança: Implementação de suporte a fundo turquesa no ícone CatIcon conforme design Paper

  • Novas Props:

    interface CatIconProps {
      size?: number; // tamanho do SVG (padrão: 48px)
      color?: string; // cor do ícone (padrão: #14120F)
      withBackground?: boolean; // ativar fundo (padrão: false)
      backgroundColor?: string; // cor do fundo (padrão: #38BCC2 — accent turquesa)
    }
    
  • Implementação: Quando withBackground={true}, o componente envolve o SVG em um View com:

    • Tamanho: 80×80px
    • Background color: #38BCC2 (accent turquesa)
    • Border radius: 24px
    • Flexbox centered
  • Uso na tela de Login (G3-0):

    // Antes:
    <View style={styles.logoContainer}>
      <CatIcon size={48} color={colors.accent} />
    </View>
    
    // Depois:
    <CatIcon size={48} color="#1E1B18" withBackground backgroundColor={colors.accent} />
    
  • Remoção: Deletado logoContainer style antigo do StyleSheet

  • Resultado: Ícone agora exibe com fundo turquesa arredondado, alinhado com design do Paper

  • Impacto: Visual correto da tela de login (G3-0) sincronizado com mockups

7. Color Picker — Modal Customizada (Substituindo Alert.prompt)

  • Arquivos: app/(app)/inventory/new.tsx, app/(app)/inventory/[id]/edit.tsx

  • Problema: Alert.prompt() apresentava bugs e comportamentos inconsistentes em React Native/Expo

  • Solução: Implementada Modal customizada com componentes nativos

  • Mudanças:

    • Adicionadas importações: Modal, KeyboardAvoidingView, Platform do React Native
    • Novos estados: isColorPickerOpen (boolean), colorInputValue (string)
    • Funções de controle:
      • handleOpenColorPicker(): abre modal focando no input
      • handleConfirmColor(): valida hex, atualiza cor, fecha modal
      • handleCancelColor(): descarta input, fecha modal
    • JSX: Modal envolta em Fragment com overlay semi-transparente
    • Estilos novos: modalOverlay, modalContent, modalCard, modalInput, modalButtons, etc.
  • Código de exemplo:

    const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
    const [colorInputValue, setColorInputValue] = useState('');
    
    const handleConfirmColor = (): void => {
      if (!colorInputValue) {
        Alert.alert('Erro', 'Digite um código hexadecimal');
        return;
      }
      const normalized = normalizeHex(colorInputValue.trim());
      if (isValidHex(normalized)) {
        setSelectedColor(normalized);
        setHexInput(normalized);
        setIsColorPickerOpen(false);
        setColorInputValue('');
      } else {
        Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
      }
    };
    
    // No JSX:
    <Modal visible={isColorPickerOpen} transparent animationType="fade">
      <View style={styles.modalOverlay}>
        <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
          <View style={styles.modalCard}>
            <TextInput
              placeholder="#FF5733"
              value={colorInputValue}
              onChangeText={setColorInputValue}
              autoFocus
            />
            {/* Botões confirmar/cancelar */}
          </View>
        </KeyboardAvoidingView>
      </View>
    </Modal>
    
  • Impacto:

    • Color picker agora funciona consistentemente em todas plataformas
    • UX melhorada com feedback visual imediato
    • Suporte completo a teclado (autoFocus, KeyboardAvoidingView)
    • Validação de hex pré-digitação com isValidHex() e normalizeHex()

Arquitetura Mantida

  • Todos use cases injetados via container.ts (Dependency Injection)
  • Zod schemas usados para validação de formulários
  • JWT interceptor em Axios automaticamente adiciona Authorization header
  • Error handling com Alert.alert() visível ao usuário

Executando o Projeto

# Instalar dependências (na pasta mobile/)
mise install   # garante Node 22 e Java 17
npm install

# Iniciar servidor de desenvolvimento (Expo Go — sem câmera ML Kit)
npx expo start

# Build nativo Android (necessário para expo-camera ML Kit barcode scan)
mise exec -- npx expo run:android

# Build nativo iOS
mise exec -- npx expo run:ios

Variável de ambiente: crie mobile/.env com:

EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1

Atenção ao build Android: o projeto usa namespace "com.meowspool" no build.gradle mas o código fonte fica em com.meowspool.app. Por isso MainActivity.kt e MainApplication.kt importam explicitamente com.meowspool.R e com.meowspool.BuildConfig, e o AndroidManifest.xml usa nomes totalmente qualificados (com.meowspool.app.MainApplication, com.meowspool.app.MainActivity). Não usar nomes relativos (.MainApplication) no manifest — eles resolvem para com.meowspool.* (sem .app), causando ClassNotFoundException em runtime.