- 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.
46 KiB
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órioandroid/comitado). Qualquer novo pacote com módulo nativo exigenpx 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
Deep links
- Scheme:
meowspool://(configurado emapp.jsoncomoscheme: "meowspool") - Filamento:
meowspool://filament/<id>→/(app)/inventory/<id>- Handler:
app/filament/[id].tsxcom<Redirect>do expo-router - Mesmo link usado em QR Code, NFC e links compartilhados
- Handler:
- Verificação de e-mail:
meowspool://verify-email?token=<token>- Handler:
app/verify-email.tsx— chamaVerifyEmailUseCase.execute(token)automaticamente - Exibe estados: carregando → sucesso → erro (token inválido/expirado)
- Redireciona para
/(auth)/loginao concluir
- Handler:
- Reset de senha:
meowspool://reset-password?token=<token>- Handler:
app/(auth)/reset-password.tsx— lêtokenviauseLocalSearchParams
- Handler:
- 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) parameowspool://verify-email?token=xxx. O OS reconhece o scheme e abre o app.
APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1APP_SCHEME=meowspool
Estratégia Offline-First
- Escrita: toda mutação persiste primeiro no SQLite (
synced = 0) e enfileira emsync_queue. - Leitura: lê sempre do SQLite; a API é usada apenas para sync.
- Sync: background job consome
sync_queuee envia para o backend Rust. - Conflito: last-write-wins via
updatedAt(ISO 8601). O backend é a fonte de verdade em conflitos. - 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 comouseXxxStore
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
SafeAreaViewcombackgroundColor: colors.bgBase - Header manual (não usa o header do expo-router):
headerShown: false - CTA fixo:
Viewcompadding: spacing[5]+borderTopWidth: 1+borderTopColor: colors.border - ScrollViews com
showsVerticalScrollIndicator={false}ekeyboardShouldPersistTaps="handled"
Formulários
react-hook-form+zodResolverpara todos os formulários- Validações no schema Zod (nunca inline)
isLoadinglocal 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_queueSQLite → API - Integrar
react-native-qrcode-svgpara render real do QR Code - Scanner de QR Code com
expo-camerav17 (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]);
onGoogleLoginagora é síncrono e apenas chamapromptAsync()- 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 namecom.meowspool.app - iOS (client
GOOGLE_CLIENT_ID_IOS): bundle IDcom.meowspool.app
Nota: o
expo-auth-sessioncomGoogle.useAuthRequestfunciona em development build (não no Expo Go). Requermise exec -- npx expo run:androidapó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_URLremovido (não mais necessário) - Import
httpClientadicionado 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.tsxelogin.tsxpassam o email como query param:/(auth)/check-email?email=xxxcheck-email.tsxlêemailviauseLocalSearchParamse 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
emailestiver disponível nos params
Cadeia adicionada:
AuthRepository.resendVerification(email)— novo método no portApiAuthRepository.resendVerification—POST /auth/resend-verificationResendVerificationUseCase— valida email não vazio e delega ao reporesendVerificationUseCaseexportado docontainer.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.tsx → app/(auth)/check-email.tsx
- Motivo: no expo-router, grupos
(auth)são transparentes na URL.app/(auth)/verify-email.tsxeapp/verify-email.tsxcompetiam 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— retornamvoid(backend não emite mais tokens no registro)register.tsx— removesetSession, navega para/(auth)/check-emailapós cadastro (usuário fica não autenticado)login.tsx— detectaEMAIL_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:Sem esse fix, o guard redirecionavaconst inPublicRoute = segments[0] === 'verify-email' || segments[0] === 'filament'; // ... } else if (!isAuthenticated && !inAuthGroup && !inPublicRoute) { router.replace('/(auth)/login'); }verify-emailpara 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 |
✅ Redirect HTTP → Deep Link (fix compatibilidade com clientes de e-mail)
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.
✅ Deep Link de Verificação de E-mail — app/verify-email.tsx
Arquivos criados/modificados: app/verify-email.tsx, app/_layout.tsx, src/application/auth/AuthUseCases.ts, src/infrastructure/container.ts
Fluxo
- Backend envia e-mail com link
https://meowspool.felipecncloud.com/api/v1/verify-email?token=<token>após o registro - Usuário toca no link → browser abre → backend redireciona 302 →
meowspool://verify-email?token=<token> - OS reconhece o scheme → expo-router roteia para
app/verify-email.tsx - A tela lê
tokenviauseLocalSearchParamse chamaVerifyEmailUseCase.execute(token)emuseEffect - Exibe estado loading durante a chamada, sucesso ou erro (token inválido / expirado / já usado)
- 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-emailcom{ token })verifyEmailUseCaseexportado docontainer.ts- Rota
verify-emailregistrada noStackdoapp/_layout.tsx
✅ Deep Link Handler — app/filament/[id].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(artboard1RY-0): bottom sheet modal com animação de ondas pulsantes (3 anéisAnimated), badge "LENDO...", título/subtítulo e botão Cancelar. Apresentado comopresentation: '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-systemcomoexpo-file-system/legacy— a API padrão do SDK 54 não exportaEncodingTypenemwriteAsStringAsyncdiretamente - Fluxo PDF:
GET /label.pdf→ resposta comoarraybuffer→btoapara Base64 → salvar comFileSystem.writeAsStringAsync(encoding Base64) → Android:IntentLauncher.startActivityAsync('android.intent.action.VIEW', ...)comcontent://URI viaFileSystem.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-sharingusaACTION_SEND, que não é declarado pelo Niimbot (e outros apps de impressão). Esses apps declaram apenasACTION_VIEW. UsarIntentLaunchercomACTION_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 comofieldsquery 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 chamadeleteFilamentUseCase.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
RefreshControlao FlatList com chamada alistFilamentsUseCase.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 chamaupdateFilamentUseCase.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 usaAlert.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:
deleteFilamentUseCase→removeFilament() - Update:
updateFilamentUseCase→updateFilament(result) - Refresh:
listFilamentsUseCase→setFilaments(result) - Create:
createFilamentUseCase→addFilament(result)
- Delete:
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 umViewcom:- 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
logoContainerstyle antigo doStyleSheet -
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,Platformdo React Native - Novos estados:
isColorPickerOpen(boolean),colorInputValue(string) - Funções de controle:
handleOpenColorPicker(): abre modal focando no inputhandleConfirmColor(): valida hex, atualiza cor, fecha modalhandleCancelColor(): descarta input, fecha modal
- JSX: Modal envolta em Fragment com overlay semi-transparente
- Estilos novos:
modalOverlay,modalContent,modalCard,modalInput,modalButtons, etc.
- Adicionadas importações:
-
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()enormalizeHex()
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
Authorizationheader - 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/.envcom:EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1
Atenção ao build Android: o projeto usa
namespace "com.meowspool"nobuild.gradlemas o código fonte fica emcom.meowspool.app. Por issoMainActivity.kteMainApplication.ktimportam explicitamentecom.meowspool.Recom.meowspool.BuildConfig, e oAndroidManifest.xmlusa nomes totalmente qualificados (com.meowspool.app.MainApplication,com.meowspool.app.MainActivity). Não usar nomes relativos (.MainApplication) no manifest — eles resolvem paracom.meowspool.*(sem.app), causandoClassNotFoundExceptionem runtime.