From 6a78cb41d8865aa0479f604def6cc912cb68512e Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sat, 14 Mar 2026 20:14:06 -0300 Subject: [PATCH] feat: integrate NFC functionality for reading and writing tags - Added NFCManagerRepository to handle NFC operations using react-native-nfc-manager. - Implemented ReadNFCTagUseCase and WriteNFCTagUseCase for reading and writing NFC tags. - Updated app layout to include NFC reader screen. - Created WriteNFCScreen for writing NFC tags with filament data. - Enhanced HomeScreen to support NFC reading and added NFC button in FilamentDetailScreen. - Updated app.json to include react-native-nfc-manager dependency. - Added animations and user feedback for NFC operations in the UI. --- .claude/settings.local.json | 3 +- mobile/agent.md | 95 +++++- mobile/app.json | 3 +- mobile/app/(app)/(tabs)/home.tsx | 40 ++- mobile/app/(app)/_layout.tsx | 4 +- mobile/app/(app)/filaments/[id]/write-nfc.tsx | 271 ++++++++++++++++++ mobile/app/(app)/inventory/[id].tsx | 28 +- mobile/app/(app)/nfc-reader.tsx | 229 +++++++++++++++ mobile/package-lock.json | 39 ++- mobile/package.json | 1 + .../src/adapters/nfc/NFCManagerRepository.ts | 61 ++++ .../src/application/nfc/ReadNFCTagUseCase.ts | 21 ++ .../src/application/nfc/WriteNFCTagUseCase.ts | 10 + mobile/src/infrastructure/container.ts | 9 + mobile/src/ports/INFCRepository.ts | 6 + 15 files changed, 797 insertions(+), 23 deletions(-) create mode 100644 mobile/app/(app)/filaments/[id]/write-nfc.tsx create mode 100644 mobile/app/(app)/nfc-reader.tsx create mode 100644 mobile/src/adapters/nfc/NFCManagerRepository.ts create mode 100644 mobile/src/application/nfc/ReadNFCTagUseCase.ts create mode 100644 mobile/src/application/nfc/WriteNFCTagUseCase.ts create mode 100644 mobile/src/ports/INFCRepository.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8123d27..eba9fbf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(mise exec:*)", "Bash(grep -rE '\"\"@adapters\"\"' tsconfig*.json babel.config.*)", "Bash(grep -n \"pub struct Line\\\\|pub fn add_shape\\\\|fn add_polygon\\\\|fn add_rect\\\\|PdfLayerReference\" /home/felipecn/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/printpdf-0.7.0/src/*.rs)", - "mcp__paper__get_basic_info" + "mcp__paper__get_basic_info", + "mcp__paper__get_screenshot" ] } } diff --git a/mobile/agent.md b/mobile/agent.md index d47335b..c0ced72 100644 --- a/mobile/agent.md +++ b/mobile/agent.md @@ -2,10 +2,12 @@ ## Visão Geral -Aplicação mobile do **MeowSpool** construída com **React Native** + **Expo** (SDK ~51), 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. +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 @@ -27,7 +29,9 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi | 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 | @@ -42,7 +46,8 @@ src/ ├── application/ ← Use Cases (orquestram domínio + ports) ├── adapters/ │ ├── remote/ ← Implementações HTTP (Axios → API Rust) -│ └── local/ ← Implementações SQLite (expo-sqlite) +│ ├── 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/ @@ -104,10 +109,12 @@ mobile/ │ │ └── [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) +│ ├── label.tsx ← Exportar Etiqueta (PDF ou SVG) +│ └── write-nfc.tsx ← Gravar tag NTAG215 com deep link do filamento │ └── src/ ├── domain/ @@ -118,7 +125,8 @@ mobile/ ├── ports/ │ ├── FilamentRepository.ts ← interface IFilamentRepository │ ├── SpoolPresetRepository.ts ← interface ISpoolPresetRepository - │ └── AuthRepository.ts ← interface IAuthRepository + │ ├── AuthRepository.ts ← interface IAuthRepository + │ └── INFCRepository.ts ← interface INFCRepository (isSupported, readTag, writeTag, cancelSession) ├── application/ │ ├── filament/ │ │ ├── CreateFilamentUseCase.ts @@ -127,18 +135,23 @@ mobile/ │ │ └── DeleteFilamentUseCase.ts │ ├── preset/ │ │ └── PresetUseCases.ts ← List, Create, Update, Delete - │ └── auth/ - │ └── AuthUseCases.ts ← Login, Register, Google, Logout, etc. + │ ├── auth/ + │ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, etc. + │ └── 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 + │ ├── 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 @@ -211,6 +224,7 @@ Arquivo: `src/shared/theme.ts` - Filamento: `meowspool://filament/` → `/(app)/inventory/` (singular, alinhado com backend) - O scanner (`scanner.tsx`) faz match via `/meowspool:\/\/filament\/([^/]+)/` e navega para `/(app)/inventory/` - O QR Code de cada filamento exibe `meowspool://filament/` usando `react-native-qrcode-svg` +- As tags NFC NTAG215 gravam a mesma URI `meowspool://filament/` como NDEF URI record — mesmo deep link, infraestrutura compartilhada --- @@ -335,6 +349,9 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) - [x] Exportar etiqueta SVG via `GET /filaments/:id/label.svg` - [x] Exportar etiqueta PDF via `GET /filaments/:id/label.pdf` - [x] Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta +- [x] NFC leitura NTAG215 → `nfc-reader.tsx` (bottom sheet modal) +- [x] 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 - [ ] Google OAuth com `expo-auth-session` - [ ] Testes de integração com Jest + Testing Library @@ -343,6 +360,64 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) ## Mudanças Recentes (14/03/2026) +### ✅ 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/`. +- **`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. + +```ts +// Padrão correto: +async isSupported(): Promise { + 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 { + 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: + +```bash +mise exec -- npx expo run:android +``` + +**Permissões Android** (adicionadas automaticamente pelo plugin no `app.json`): +```xml + +``` + +**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` diff --git a/mobile/app.json b/mobile/app.json index 08a9c47..cec1abe 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -35,7 +35,8 @@ { "cameraPermission": "O MeowSpool precisa da câmera para escanear QR Codes de filamentos." } - ] + ], + "react-native-nfc-manager" ], "experiments": { "typedRoutes": true diff --git a/mobile/app/(app)/(tabs)/home.tsx b/mobile/app/(app)/(tabs)/home.tsx index 9aa740b..f9f8dcd 100644 --- a/mobile/app/(app)/(tabs)/home.tsx +++ b/mobile/app/(app)/(tabs)/home.tsx @@ -1,7 +1,8 @@ import React, { useEffect } from 'react'; import { - View, Text, ScrollView, TouchableOpacity, StyleSheet, + View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, } from 'react-native'; +import { SvgXml } from 'react-native-svg'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -13,6 +14,16 @@ import { Card } from '@presentation/components/ui/Card'; import { calcFilamentPercentage } from '@domain/Filament'; import { formatWeight } from '@shared/utils/filament'; import { colors, typography, spacing, radius } from '@shared/theme'; +import { nfcRepository } from '@infrastructure/container'; + +const NFC_SVG = ` + + + + + + +`; /** * Tela Home / Dashboard — 1-0 @@ -27,6 +38,15 @@ import { colors, typography, spacing, radius } from '@shared/theme'; export default function HomeScreen(): React.ReactElement { const router = useRouter(); const { user } = useAuthStore(); + + async function handleNFCRead(): Promise { + const supported = await nfcRepository.isSupported(); + if (!supported) { + Alert.alert('NFC indisponível', 'Este dispositivo não possui suporte a NFC.'); + return; + } + router.push('/(app)/nfc-reader' as never); + } const { filaments, isLoading } = useFilamentStore(); // Dados derivados @@ -54,12 +74,17 @@ export default function HomeScreen(): React.ReactElement { Bem-vindo de volta, Seu Inventário - router.push('/(app)/scanner' as never)} - > - - + + + + + router.push('/(app)/scanner' as never)} + > + + + {/* Totais */} @@ -203,6 +228,7 @@ const styles = StyleSheet.create({ headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: spacing[2] }, welcomeText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary }, title: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.textPrimary }, + headerBtns: { flexDirection: 'row', gap: spacing[2] }, qrBtn: { width: 40, height: 40, borderRadius: radius.md, backgroundColor: colors.bgSurface, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border }, statsRow: { flexDirection: 'row', gap: spacing[3] }, statCard: { flex: 1, gap: spacing[1] }, diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 8a4878f..4874faf 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -3,6 +3,8 @@ import { Stack } from 'expo-router'; export default function AppLayout(): React.ReactElement { return ( - + + + ); } diff --git a/mobile/app/(app)/filaments/[id]/write-nfc.tsx b/mobile/app/(app)/filaments/[id]/write-nfc.tsx new file mode 100644 index 0000000..89a02b0 --- /dev/null +++ b/mobile/app/(app)/filaments/[id]/write-nfc.tsx @@ -0,0 +1,271 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Animated, +} from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { SvgXml } from 'react-native-svg'; +import { colors, typography, spacing, radius } from '@shared/theme'; +import { writeNFCTagUseCase, nfcRepository } from '@infrastructure/container'; +import { Button } from '@presentation/components/ui/Button'; + +const NFC_SVG = ` + + + + + + +`; + +type Status = 'waiting' | 'writing' | 'success' | 'error'; + +/** + * Tela de gravação NFC — acessada a partir do Detalhe do Filamento + */ +export default function WriteNFCScreen(): React.ReactElement { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const [status, setStatus] = useState('waiting'); + const [errorMessage, setErrorMessage] = useState(''); + const ring1 = useRef(new Animated.Value(0)).current; + const ring2 = useRef(new Animated.Value(0)).current; + const ring3 = useRef(new Animated.Value(0)).current; + const animRef = useRef([]); + + useEffect(() => { + startPulse(); + startWriting(); + return () => { + stopPulse(); + nfcRepository.cancelSession().catch(() => {}); + }; + }, []); + + function startPulse(): void { + const pulse = (ring: Animated.Value, delay: number) => + Animated.loop( + Animated.sequence([ + Animated.delay(delay), + Animated.timing(ring, { toValue: 1, duration: 1400, useNativeDriver: true }), + Animated.timing(ring, { toValue: 0, duration: 0, useNativeDriver: true }), + ]), + ); + const a1 = pulse(ring1, 0); + const a2 = pulse(ring2, 400); + const a3 = pulse(ring3, 800); + animRef.current = [a1, a2, a3]; + a1.start(); a2.start(); a3.start(); + } + + function stopPulse(): void { + animRef.current.forEach((a) => a.stop()); + ring1.setValue(0); ring2.setValue(0); ring3.setValue(0); + } + + async function startWriting(): Promise { + setStatus('writing'); + try { + await writeNFCTagUseCase.execute(id); + stopPulse(); + setStatus('success'); + } catch (err: unknown) { + stopPulse(); + setErrorMessage(err instanceof Error ? err.message : 'Erro ao gravar a tag.'); + setStatus('error'); + } + } + + const ringStyle = (anim: Animated.Value) => ({ + opacity: anim.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0.5, 0.15, 0] }), + transform: [{ scale: anim.interpolate({ inputRange: [0, 1], outputRange: [1, 2.2] }) }], + }); + + return ( + + {/* Header */} + + { + nfcRepository.cancelSession().catch(() => {}); + router.back(); + }}> + + + Gravar NFC + + + + {/* Content */} + + {/* Icon with rings */} + + {status === 'writing' && ( + <> + + + + + )} + + {status === 'success' + ? + : status === 'error' + ? + : + } + + + + {/* Status text */} + {status === 'waiting' || status === 'writing' ? ( + <> + Aproxime a tag NTAG215 + + Encoste a tag NFC ao sensor do dispositivo para gravar os dados do filamento + + + ) : status === 'success' ? ( + <> + Tag gravada! + + A tag NFC foi configurada com sucesso para este filamento + + + ) : ( + <> + Falha na gravação + {errorMessage} + + )} + + + {/* Footer actions */} + + {status === 'success' ? ( +