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:
2026-03-14 20:14:06 -03:00
parent 74adc35b53
commit 6a78cb41d8
15 changed files with 797 additions and 23 deletions
+33 -7
View File
@@ -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,12 +74,17 @@ export default function HomeScreen(): React.ReactElement {
<Text style={styles.welcomeText}>Bem-vindo de volta,</Text>
<Text style={styles.title}>Seu Inventário</Text>
</View>
<TouchableOpacity
style={styles.qrBtn}
onPress={() => router.push('/(app)/scanner' as never)}
>
<Ionicons name="qr-code-outline" size={22} color={colors.textPrimary} />
</TouchableOpacity>
<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)}
>
<Ionicons name="qr-code-outline" size={22} color={colors.textPrimary} />
</TouchableOpacity>
</View>
</View>
{/* 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] },
+3 -1
View File
@@ -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,
},
});
+27 -1
View File
@@ -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>
+229
View File
@@ -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,
},
});