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.
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+85
-10
@@ -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/<id>` → `/(app)/inventory/<id>` (singular, alinhado com backend)
|
||||
- O scanner (`scanner.tsx`) faz match via `/meowspool:\/\/filament\/([^/]+)/` e navega para `/(app)/inventory/<id>`
|
||||
- O QR Code de cada filamento exibe `meowspool://filament/<id>` usando `react-native-qrcode-svg`
|
||||
- As tags NFC NTAG215 gravam a mesma URI `meowspool://filament/<id>` 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/<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.
|
||||
|
||||
```ts
|
||||
// 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:
|
||||
|
||||
```bash
|
||||
mise exec -- npx expo run:android
|
||||
```
|
||||
|
||||
**Permissões Android** (adicionadas automaticamente pelo plugin no `app.json`):
|
||||
```xml
|
||||
<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`
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@
|
||||
{
|
||||
"cameraPermission": "O MeowSpool precisa da câmera para escanear QR Codes de filamentos."
|
||||
}
|
||||
]
|
||||
],
|
||||
"react-native-nfc-manager"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g stroke="${colors.textPrimary}" stroke-width="10" fill="none">
|
||||
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49"/>
|
||||
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80"/>
|
||||
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11"/>
|
||||
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43"/>
|
||||
</g>
|
||||
</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<void> {
|
||||
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,6 +74,10 @@ export default function HomeScreen(): React.ReactElement {
|
||||
<Text style={styles.welcomeText}>Bem-vindo de volta,</Text>
|
||||
<Text style={styles.title}>Seu Inventário</Text>
|
||||
</View>
|
||||
<View style={styles.headerBtns}>
|
||||
<TouchableOpacity style={styles.qrBtn} onPress={handleNFCRead}>
|
||||
<SvgXml xml={NFC_SVG} width={22} height={22} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.qrBtn}
|
||||
onPress={() => router.push('/(app)/scanner' as never)}
|
||||
@@ -61,6 +85,7 @@ export default function HomeScreen(): React.ReactElement {
|
||||
<Ionicons name="qr-code-outline" size={22} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Totais */}
|
||||
<View style={styles.statsRow}>
|
||||
@@ -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] },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Stack } from 'expo-router';
|
||||
|
||||
export default function AppLayout(): React.ReactElement {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }} />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }}>
|
||||
<Stack.Screen name="nfc-reader" options={{ presentation: 'transparentModal', animation: 'slide_from_bottom' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g stroke="${colors.textSecondary}" stroke-width="10" fill="none">
|
||||
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49"/>
|
||||
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80"/>
|
||||
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11"/>
|
||||
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43"/>
|
||||
</g>
|
||||
</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<Status>('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<Animated.CompositeAnimation[]>([]);
|
||||
|
||||
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<void> {
|
||||
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 (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
nfcRepository.cancelSession().catch(() => {});
|
||||
router.back();
|
||||
}}>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Gravar NFC</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Icon with rings */}
|
||||
<View style={styles.iconArea}>
|
||||
{status === 'writing' && (
|
||||
<>
|
||||
<Animated.View style={[styles.ring, ringStyle(ring3)]} />
|
||||
<Animated.View style={[styles.ring, ringStyle(ring2)]} />
|
||||
<Animated.View style={[styles.ring, ringStyle(ring1)]} />
|
||||
</>
|
||||
)}
|
||||
<View style={[
|
||||
styles.iconCircle,
|
||||
status === 'success' && styles.iconCircleSuccess,
|
||||
status === 'error' && styles.iconCircleError,
|
||||
]}>
|
||||
{status === 'success'
|
||||
? <Ionicons name="checkmark" size={40} color={colors.stockOk} />
|
||||
: status === 'error'
|
||||
? <Ionicons name="close" size={40} color={colors.error} />
|
||||
: <SvgXml xml={NFC_SVG} width={40} height={40} />
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status text */}
|
||||
{status === 'waiting' || status === 'writing' ? (
|
||||
<>
|
||||
<Text style={styles.title}>Aproxime a tag NTAG215</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Encoste a tag NFC ao sensor do dispositivo para gravar os dados do filamento
|
||||
</Text>
|
||||
</>
|
||||
) : status === 'success' ? (
|
||||
<>
|
||||
<Text style={styles.title}>Tag gravada!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
A tag NFC foi configurada com sucesso para este filamento
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.titleError}>Falha na gravação</Text>
|
||||
<Text style={styles.subtitle}>{errorMessage}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Footer actions */}
|
||||
<View style={styles.footer}>
|
||||
{status === 'success' ? (
|
||||
<Button label="Concluir" onPress={() => router.back()} />
|
||||
) : status === 'error' ? (
|
||||
<>
|
||||
<Button label="Tentar novamente" onPress={startWriting} />
|
||||
<TouchableOpacity style={styles.cancelBtn} onPress={() => router.back()}>
|
||||
<Text style={styles.cancelText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.cancelBtn}
|
||||
onPress={() => {
|
||||
nfcRepository.cancelSession().catch(() => {});
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const RING_SIZE = 120;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bgBase },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing[5],
|
||||
paddingVertical: spacing[4],
|
||||
},
|
||||
headerTitle: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.md,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing[4],
|
||||
paddingHorizontal: spacing[6],
|
||||
},
|
||||
iconArea: {
|
||||
width: RING_SIZE * 2.2,
|
||||
height: RING_SIZE * 2.2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing[2],
|
||||
},
|
||||
ring: {
|
||||
position: 'absolute',
|
||||
width: RING_SIZE,
|
||||
height: RING_SIZE,
|
||||
borderRadius: RING_SIZE / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
iconCircle: {
|
||||
width: RING_SIZE,
|
||||
height: RING_SIZE,
|
||||
borderRadius: RING_SIZE / 2,
|
||||
backgroundColor: colors.bgHover,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconCircleSuccess: {
|
||||
borderColor: colors.stockOk,
|
||||
backgroundColor: `${colors.stockOk}15`,
|
||||
},
|
||||
iconCircleError: {
|
||||
borderColor: colors.error,
|
||||
backgroundColor: `${colors.error}15`,
|
||||
},
|
||||
title: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xl,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
titleError: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xl,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.error,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
padding: spacing[5],
|
||||
gap: spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
cancelBtn: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing[3],
|
||||
},
|
||||
cancelText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,17 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useFilamentStore } from '@store/filamentStore';
|
||||
import { usePresetStore } from '@store/presetStore';
|
||||
import { useAuthStore } from '@store/authStore';
|
||||
import { deleteFilamentUseCase } from '@infrastructure/container';
|
||||
import { deleteFilamentUseCase, nfcRepository } from '@infrastructure/container';
|
||||
import { SvgXml } from 'react-native-svg';
|
||||
|
||||
const NFC_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g stroke="${colors.textSecondary}" stroke-width="10" fill="none">
|
||||
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49"/>
|
||||
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80"/>
|
||||
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11"/>
|
||||
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
import { calcFilamentPercentage } from '@domain/Filament';
|
||||
import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme';
|
||||
import { formatWeight } from '@shared/utils/filament';
|
||||
@@ -45,6 +55,15 @@ export default function FilamentDetailScreen(): React.ReactElement {
|
||||
const stockColor = getStockColor(pct);
|
||||
const slug = `${filament.brand.toLowerCase()}-${(filament.model ?? '').toLowerCase()}-${filament.colorHex.replace('#', '').toLowerCase()}`.replace(/\s+/g, '-');
|
||||
|
||||
async function handleWriteNFC(): Promise<void> {
|
||||
const supported = await nfcRepository.isSupported();
|
||||
if (!supported) {
|
||||
Alert.alert('NFC indisponível', 'Este dispositivo não possui suporte a NFC.');
|
||||
return;
|
||||
}
|
||||
router.push(`/(app)/filaments/${id}/write-nfc` as never);
|
||||
}
|
||||
|
||||
function handleDelete(): void {
|
||||
Alert.alert(
|
||||
'Excluir filamento',
|
||||
@@ -188,6 +207,13 @@ export default function FilamentDetailScreen(): React.ReactElement {
|
||||
<Ionicons name="qr-code-outline" size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.idBtnTextSecondary}>Ver QR</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.idBtn, styles.idBtnSecondary]}
|
||||
onPress={handleWriteNFC}
|
||||
>
|
||||
<SvgXml xml={NFC_SVG} width={14} height={14} />
|
||||
<Text style={styles.idBtnTextSecondary}>Gravar NFC</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { SvgXml } from 'react-native-svg';
|
||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||
import { readNFCTagUseCase, nfcRepository } from '@infrastructure/container';
|
||||
|
||||
const NFC_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g stroke="${colors.textSecondary}" stroke-width="10" fill="none">
|
||||
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49"/>
|
||||
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80"/>
|
||||
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11"/>
|
||||
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* Bottom sheet modal de leitura NFC — artboard 1RY-0
|
||||
*/
|
||||
export default function NFCReaderScreen(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const ring1 = useRef(new Animated.Value(0)).current;
|
||||
const ring2 = useRef(new Animated.Value(0)).current;
|
||||
const ring3 = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
a1.start();
|
||||
a2.start();
|
||||
a3.start();
|
||||
|
||||
startReading();
|
||||
|
||||
return () => {
|
||||
a1.stop();
|
||||
a2.stop();
|
||||
a3.stop();
|
||||
nfcRepository.cancelSession().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startReading(): Promise<void> {
|
||||
try {
|
||||
const filamentId = await readNFCTagUseCase.execute();
|
||||
router.replace(`/(app)/inventory/${filamentId}` as never);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Erro ao ler a tag.';
|
||||
Alert.alert('Erro NFC', message, [{ text: 'OK', onPress: () => router.back() }]);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.overlay}>
|
||||
{/* Bottom sheet */}
|
||||
<View style={styles.sheet}>
|
||||
{/* Drag handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
{/* NFC animation area */}
|
||||
<View style={styles.iconArea}>
|
||||
<Animated.View style={[styles.ring, ringStyle(ring3)]} />
|
||||
<Animated.View style={[styles.ring, ringStyle(ring2)]} />
|
||||
<Animated.View style={[styles.ring, ringStyle(ring1)]} />
|
||||
<View style={styles.iconCircle}>
|
||||
<SvgXml xml={NFC_SVG} width={40} height={40} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status badge */}
|
||||
<View style={styles.badge}>
|
||||
<View style={styles.badgeDot} />
|
||||
<Text style={styles.badgeText}>LENDO...</Text>
|
||||
</View>
|
||||
|
||||
{/* Text */}
|
||||
<Text style={styles.title}>Aguardando NFC</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Aproxime a tag do carretel ao sensor NFC do seu dispositivo
|
||||
</Text>
|
||||
|
||||
{/* Cancel */}
|
||||
<TouchableOpacity
|
||||
style={styles.cancelBtn}
|
||||
onPress={() => {
|
||||
nfcRepository.cancelSession().catch(() => {});
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const RING_SIZE = 120;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: colors.bgSurface,
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
paddingBottom: spacing[10],
|
||||
paddingHorizontal: spacing[6],
|
||||
alignItems: 'center',
|
||||
gap: spacing[4],
|
||||
paddingTop: spacing[3],
|
||||
},
|
||||
handle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.border,
|
||||
marginBottom: spacing[4],
|
||||
},
|
||||
iconArea: {
|
||||
width: RING_SIZE * 2.2,
|
||||
height: RING_SIZE * 2.2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ring: {
|
||||
position: 'absolute',
|
||||
width: RING_SIZE,
|
||||
height: RING_SIZE,
|
||||
borderRadius: RING_SIZE / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
iconCircle: {
|
||||
width: RING_SIZE,
|
||||
height: RING_SIZE,
|
||||
borderRadius: RING_SIZE / 2,
|
||||
backgroundColor: colors.bgHover,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing[2],
|
||||
backgroundColor: colors.bgHover,
|
||||
paddingHorizontal: spacing[3],
|
||||
paddingVertical: spacing[1],
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
badgeDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.stockOk,
|
||||
},
|
||||
badgeText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.textSecondary,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
title: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xl,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
cancelBtn: {
|
||||
marginTop: spacing[2],
|
||||
backgroundColor: colors.bgHover,
|
||||
borderRadius: radius.md,
|
||||
paddingVertical: spacing[4],
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cancelText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
Generated
+37
-2
@@ -31,6 +31,7 @@
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-nfc-manager": "^3.17.2",
|
||||
"react-native-qrcode-svg": "^6.3.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
@@ -3872,7 +3873,7 @@
|
||||
"version": "19.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@@ -5595,7 +5596,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
@@ -10837,6 +10838,26 @@
|
||||
"ws": "^7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom/node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
@@ -10959,6 +10980,20 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nfc-manager": {
|
||||
"version": "3.17.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-nfc-manager/-/react-native-nfc-manager-3.17.2.tgz",
|
||||
"integrity": "sha512-0NryP/Iw2hzw4MVH5KCngoRerNUrnRok6VfLrlFcFZRKyTQ7KTgpsdDxCB6cR33qYNyEDrWGBayfAI+ym5gt8Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@expo/config-plugins": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@expo/config-plugins": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-qrcode-svg": {
|
||||
"version": "6.3.21",
|
||||
"resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-nfc-manager": "^3.17.2",
|
||||
"react-native-qrcode-svg": "^6.3.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import NfcManager, { Ndef, NfcTech } from 'react-native-nfc-manager';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { INFCRepository } from '@ports/INFCRepository';
|
||||
|
||||
export class NFCManagerRepository implements INFCRepository {
|
||||
private started = false;
|
||||
|
||||
private get nativeModuleAvailable(): boolean {
|
||||
return NativeModules.NfcManager != null;
|
||||
}
|
||||
|
||||
private async ensureStarted(): Promise<void> {
|
||||
if (!this.nativeModuleAvailable) {
|
||||
throw new Error('Módulo NFC não disponível. Reinstale o app.');
|
||||
}
|
||||
if (!this.started) {
|
||||
await NfcManager.start();
|
||||
this.started = true;
|
||||
}
|
||||
}
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
if (!this.nativeModuleAvailable) return false;
|
||||
try {
|
||||
return await NfcManager.isSupported();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async readTag(): Promise<string | null> {
|
||||
await this.ensureStarted();
|
||||
await NfcManager.requestTechnology(NfcTech.Ndef);
|
||||
try {
|
||||
const tag = await NfcManager.getTag();
|
||||
const ndefRecords = tag?.ndefMessage;
|
||||
if (!ndefRecords || ndefRecords.length === 0) return null;
|
||||
|
||||
const firstRecord = ndefRecords[0];
|
||||
const uri = Ndef.uri.decodePayload(firstRecord.payload as unknown as Uint8Array);
|
||||
return uri ?? null;
|
||||
} finally {
|
||||
NfcManager.cancelTechnologyRequest();
|
||||
}
|
||||
}
|
||||
|
||||
async writeTag(uri: string): Promise<void> {
|
||||
await this.ensureStarted();
|
||||
await NfcManager.requestTechnology(NfcTech.Ndef);
|
||||
try {
|
||||
const bytes = Ndef.encodeMessage([Ndef.uriRecord(uri)]);
|
||||
await NfcManager.ndefHandler.writeNdefMessage(bytes);
|
||||
} finally {
|
||||
NfcManager.cancelTechnologyRequest();
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSession(): Promise<void> {
|
||||
await NfcManager.cancelTechnologyRequest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { INFCRepository } from '@ports/INFCRepository';
|
||||
|
||||
const FILAMENT_URI_PREFIX = 'meowspool://filament/';
|
||||
|
||||
export class ReadNFCTagUseCase {
|
||||
constructor(private readonly nfcRepository: INFCRepository) {}
|
||||
|
||||
async execute(): Promise<string> {
|
||||
const uri = await this.nfcRepository.readTag();
|
||||
if (!uri) throw new Error('Tag sem dados NDEF.');
|
||||
|
||||
if (!uri.startsWith(FILAMENT_URI_PREFIX)) {
|
||||
throw new Error('Tag não reconhecida pelo MeowSpool.');
|
||||
}
|
||||
|
||||
const filamentId = uri.slice(FILAMENT_URI_PREFIX.length);
|
||||
if (!filamentId) throw new Error('ID do filamento inválido na tag.');
|
||||
|
||||
return filamentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { INFCRepository } from '@ports/INFCRepository';
|
||||
|
||||
export class WriteNFCTagUseCase {
|
||||
constructor(private readonly nfcRepository: INFCRepository) {}
|
||||
|
||||
async execute(filamentId: string): Promise<void> {
|
||||
const uri = `meowspool://filament/${filamentId}`;
|
||||
await this.nfcRepository.writeTag(uri);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { NFCManagerRepository } from '@adapters/nfc/NFCManagerRepository';
|
||||
import { ReadNFCTagUseCase } from '@application/nfc/ReadNFCTagUseCase';
|
||||
import { WriteNFCTagUseCase } from '@application/nfc/WriteNFCTagUseCase';
|
||||
import { ApiAuthRepository } from '@adapters/remote/ApiAuthRepository';
|
||||
import { ApiFilamentRepository } from '@adapters/remote/ApiFilamentRepository';
|
||||
import { ApiSpoolPresetRepository } from '@adapters/remote/ApiSpoolPresetRepository';
|
||||
@@ -42,3 +45,9 @@ export const listPresetsUseCase = new ListPresetsUseCase(presetRepository);
|
||||
export const createPresetUseCase = new CreatePresetUseCase(presetRepository);
|
||||
export const updatePresetUseCase = new UpdatePresetUseCase(presetRepository);
|
||||
export const deletePresetUseCase = new DeletePresetUseCase(presetRepository);
|
||||
|
||||
// NFC
|
||||
const nfcRepository = new NFCManagerRepository();
|
||||
export const readNFCTagUseCase = new ReadNFCTagUseCase(nfcRepository);
|
||||
export const writeNFCTagUseCase = new WriteNFCTagUseCase(nfcRepository);
|
||||
export { nfcRepository };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface INFCRepository {
|
||||
isSupported(): Promise<boolean>;
|
||||
readTag(): Promise<string | null>;
|
||||
writeTag(uri: string): Promise<void>;
|
||||
cancelSession(): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user