Initial commit

This commit is contained in:
michael
2026-01-22 13:55:55 -08:00
commit d05c02f556
31 changed files with 17832 additions and 0 deletions

2
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {useScan} from './useScan';
export type {UseScanResult} from './useScan';

186
src/hooks/useScan.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* useScan Hook
* React hook for managing NFC scan state and operations
*/
import {useState, useCallback, useEffect, useRef} from 'react';
import {nfcManager} from '../services/nfc';
import type {ScanState, RawTagData, ScanError, NFCStatus} from '../types/nfc';
export interface UseScanResult {
/** Current scan state */
state: ScanState;
/** Scanned tag data (when state is 'success') */
tag: RawTagData | null;
/** Scan error (when state is 'error') */
error: ScanError | null;
/** NFC status (supported and enabled) */
nfcStatus: NFCStatus;
/** Start scanning for a tag */
startScan: () => Promise<void>;
/** Cancel ongoing scan */
cancelScan: () => Promise<void>;
/** Reset state to idle */
reset: () => void;
/** Open device NFC settings */
openSettings: () => Promise<void>;
}
/**
* Hook for managing NFC scanning
*/
export function useScan(): UseScanResult {
const [state, setState] = useState<ScanState>('idle');
const [tag, setTag] = useState<RawTagData | null>(null);
const [error, setError] = useState<ScanError | null>(null);
const [nfcStatus, setNfcStatus] = useState<NFCStatus>({
isSupported: false,
isEnabled: false,
});
// Track if component is mounted to prevent state updates after unmount
const isMounted = useRef(true);
// Track if a scan is in progress
const scanInProgress = useRef(false);
// Initialize NFC and check status
useEffect(() => {
let mounted = true;
async function initialize() {
await nfcManager.init();
if (mounted) {
const status = await nfcManager.getStatus();
setNfcStatus(status);
}
}
initialize();
return () => {
mounted = false;
isMounted.current = false;
// Cleanup on unmount
nfcManager.cancelScan();
};
}, []);
// Start scanning for a tag
const startScan = useCallback(async () => {
// Prevent multiple concurrent scans
if (scanInProgress.current) {
return;
}
scanInProgress.current = true;
setState('scanning');
setTag(null);
setError(null);
try {
// Check NFC status first
const status = await nfcManager.getStatus();
if (isMounted.current) {
setNfcStatus(status);
}
if (!status.isSupported) {
if (isMounted.current) {
setState('error');
setError({
type: 'NFC_NOT_SUPPORTED',
message: 'NFC is not supported on this device',
});
}
return;
}
if (!status.isEnabled) {
if (isMounted.current) {
setState('error');
setError({
type: 'NFC_NOT_ENABLED',
message: 'Please enable NFC in your device settings',
});
}
return;
}
// Perform the scan
const result = await nfcManager.scanTag();
if (!isMounted.current) {
return;
}
if (result.error) {
// Don't show error for cancellation
if (result.error.type === 'SCAN_CANCELLED') {
setState('idle');
} else {
setState('error');
setError(result.error);
}
} else if (result.tag) {
setState('success');
setTag(result.tag);
} else {
setState('error');
setError({
type: 'UNKNOWN',
message: 'No tag data received',
});
}
} catch (err) {
if (isMounted.current) {
setState('error');
setError({
type: 'UNKNOWN',
message: err instanceof Error ? err.message : 'An unknown error occurred',
originalError: err,
});
}
} finally {
scanInProgress.current = false;
}
}, []);
// Cancel ongoing scan
const cancelScan = useCallback(async () => {
await nfcManager.cancelScan();
scanInProgress.current = false;
if (isMounted.current) {
setState('idle');
setError(null);
}
}, []);
// Reset state to idle
const reset = useCallback(() => {
setState('idle');
setTag(null);
setError(null);
}, []);
// Open device NFC settings
const openSettings = useCallback(async () => {
await nfcManager.openNFCSettings();
// Re-check status after returning from settings
const status = await nfcManager.getStatus();
if (isMounted.current) {
setNfcStatus(status);
}
}, []);
return {
state,
tag,
error,
nfcStatus,
startScan,
cancelScan,
reset,
openSettings,
};
}

100
src/screens/HomeScreen.tsx Normal file
View File

@@ -0,0 +1,100 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {Button, Text, Surface} from 'react-native-paper';
import type {HomeScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
export function HomeScreen({navigation}: HomeScreenProps) {
return (
<View style={styles.container}>
<Surface style={styles.header} elevation={0}>
<Text variant="displaySmall" style={styles.title}>
DANGEROUS THINGS
</Text>
<Text variant="headlineSmall" style={styles.subtitle}>
NFC IDENTIFIER
</Text>
</Surface>
<View style={styles.content}>
<Text variant="bodyLarge" style={styles.description}>
Scan any NFC transponder to find compatible Dangerous Things implants.
</Text>
<Button
mode="outlined"
onPress={() => navigation.navigate('Scan')}
style={styles.scanButton}
labelStyle={styles.scanButtonLabel}
contentStyle={styles.scanButtonContent}>
START SCAN
</Button>
</View>
<View style={styles.footer}>
<Text variant="bodySmall" style={styles.footerText}>
dngr.us
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: DTColors.dark,
padding: 24,
},
header: {
alignItems: 'center',
paddingTop: 60,
paddingBottom: 40,
backgroundColor: 'transparent',
},
title: {
color: DTColors.modeNormal,
fontWeight: '700',
letterSpacing: 2,
},
subtitle: {
color: DTColors.modeEmphasis,
marginTop: 8,
letterSpacing: 4,
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
description: {
color: DTColors.light,
textAlign: 'center',
marginBottom: 48,
opacity: 0.9,
paddingHorizontal: 20,
},
scanButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
borderRadius: 4,
},
scanButtonLabel: {
color: DTColors.modeNormal,
fontSize: 18,
fontWeight: '600',
letterSpacing: 2,
},
scanButtonContent: {
paddingVertical: 12,
paddingHorizontal: 32,
},
footer: {
alignItems: 'center',
paddingBottom: 20,
},
footerText: {
color: DTColors.modeNormal,
opacity: 0.6,
},
});

View File

@@ -0,0 +1,240 @@
import React from 'react';
import {StyleSheet, View, ScrollView, Linking} from 'react-native';
import {Button, Text, Surface, Divider} from 'react-native-paper';
import type {ResultScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
export function ResultScreen({route, navigation}: ResultScreenProps) {
const {tagData} = route.params;
const handleConversionLink = () => {
Linking.openURL('https://dngr.us/conversion');
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
<Surface style={styles.resultCard} elevation={1}>
<Text variant="labelLarge" style={styles.cardLabel}>
TAG DETECTED
</Text>
<Divider style={styles.divider} />
{tagData ? (
<>
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
UID
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.uid || 'N/A'}
</Text>
</View>
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
TECHNOLOGIES
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.techTypes.join(', ') || 'N/A'}
</Text>
</View>
{tagData.sak !== undefined && (
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
SAK
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
0x{tagData.sak.toString(16).toUpperCase().padStart(2, '0')}
</Text>
</View>
)}
{tagData.atqa && (
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
ATQA
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.atqa}
</Text>
</View>
)}
{tagData.historicalBytes && (
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
HISTORICAL BYTES
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.historicalBytes}
</Text>
</View>
)}
</>
) : (
<Text variant="bodyLarge" style={styles.noData}>
No tag data available
</Text>
)}
</Surface>
<Surface style={styles.matchCard} elevation={1}>
<Text variant="labelLarge" style={styles.matchLabel}>
COMPATIBLE IMPLANTS
</Text>
<Divider style={styles.divider} />
<Text variant="bodyLarge" style={styles.placeholderText}>
Detection coming in Phase 3-5
</Text>
<Text variant="bodyMedium" style={styles.hintText}>
Full chip identification and product matching will be implemented in
later phases.
</Text>
</Surface>
<Surface style={styles.conversionCard} elevation={1}>
<Text variant="bodyMedium" style={styles.conversionText}>
Can't find a match? Check out our conversion service.
</Text>
<Button
mode="outlined"
onPress={handleConversionLink}
style={styles.conversionButton}
labelStyle={styles.conversionButtonLabel}>
CONVERSION SERVICE
</Button>
</Surface>
<View style={styles.actions}>
<Button
mode="outlined"
onPress={() => navigation.navigate('Scan')}
style={styles.actionButton}
labelStyle={styles.actionButtonLabel}>
SCAN ANOTHER
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Home')}
labelStyle={styles.homeLabel}>
HOME
</Button>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: DTColors.dark,
},
content: {
padding: 24,
},
resultCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeNormal,
padding: 20,
marginBottom: 20,
},
cardLabel: {
color: DTColors.modeNormal,
letterSpacing: 2,
marginBottom: 12,
},
divider: {
backgroundColor: DTColors.modeNormal,
opacity: 0.3,
marginBottom: 16,
},
dataRow: {
marginBottom: 16,
},
dataLabel: {
color: DTColors.light,
opacity: 0.6,
marginBottom: 4,
letterSpacing: 1,
},
dataValue: {
color: DTColors.light,
fontFamily: 'monospace',
},
noData: {
color: DTColors.light,
opacity: 0.6,
fontStyle: 'italic',
},
matchCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeEmphasis,
padding: 20,
marginBottom: 20,
},
matchLabel: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 12,
},
placeholderText: {
color: DTColors.light,
opacity: 0.8,
marginBottom: 8,
},
hintText: {
color: DTColors.light,
opacity: 0.5,
fontStyle: 'italic',
},
conversionCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeOther,
padding: 20,
marginBottom: 32,
alignItems: 'center',
},
conversionText: {
color: DTColors.light,
textAlign: 'center',
marginBottom: 16,
opacity: 0.9,
},
conversionButton: {
borderColor: DTColors.modeOther,
borderWidth: 2,
},
conversionButtonLabel: {
color: DTColors.modeOther,
letterSpacing: 1,
},
actions: {
alignItems: 'center',
gap: 16,
},
actionButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
width: '100%',
},
actionButtonLabel: {
color: DTColors.modeNormal,
letterSpacing: 2,
},
homeLabel: {
color: DTColors.light,
opacity: 0.6,
},
});

312
src/screens/ScanScreen.tsx Normal file
View File

@@ -0,0 +1,312 @@
import React, {useCallback, useEffect} from 'react';
import {StyleSheet, View, Platform} from 'react-native';
import {Button, Text, ActivityIndicator} from 'react-native-paper';
import type {ScanScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
import {useScan} from '../hooks';
import {getScanInstructions} from '../services/nfc';
export function ScanScreen({navigation}: ScanScreenProps) {
const {
state,
tag,
error,
nfcStatus,
startScan,
cancelScan,
openSettings,
} = useScan();
// Navigate to results when scan succeeds
useEffect(() => {
if (state === 'success' && tag) {
navigation.replace('Result', {
tagData: {
uid: tag.uid,
techTypes: tag.techTypes,
sak: tag.sak,
atqa: tag.atqa,
ats: tag.ats,
historicalBytes: tag.historicalBytes,
},
});
}
}, [state, tag, navigation]);
// Auto-start scan when screen loads
useEffect(() => {
// Small delay to ensure screen is mounted
const timeout = setTimeout(() => {
startScan();
}, 300);
return () => clearTimeout(timeout);
// Only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCancel = useCallback(async () => {
await cancelScan();
navigation.goBack();
}, [cancelScan, navigation]);
const handleRetry = useCallback(() => {
startScan();
}, [startScan]);
// Render NFC not supported state
if (!nfcStatus.isSupported && state !== 'scanning') {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text variant="headlineMedium" style={styles.errorText}>
NFC NOT SUPPORTED
</Text>
<Text variant="bodyLarge" style={styles.errorMessage}>
This device does not support NFC scanning.
</Text>
</View>
<View style={styles.footer}>
<Button
mode="text"
onPress={() => navigation.goBack()}
labelStyle={styles.cancelLabel}>
GO BACK
</Button>
</View>
</View>
);
}
// Render NFC disabled state
if (!nfcStatus.isEnabled && error?.type === 'NFC_NOT_ENABLED') {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text variant="headlineMedium" style={styles.warningText}>
NFC DISABLED
</Text>
<Text variant="bodyLarge" style={styles.errorMessage}>
Please enable NFC in your device settings to scan tags.
</Text>
{Platform.OS === 'android' && (
<Button
mode="outlined"
onPress={openSettings}
style={styles.settingsButton}
labelStyle={styles.buttonLabel}>
OPEN SETTINGS
</Button>
)}
</View>
<View style={styles.footer}>
<Button
mode="text"
onPress={handleRetry}
labelStyle={styles.retryLabel}>
TRY AGAIN
</Button>
<Button
mode="text"
onPress={() => navigation.goBack()}
labelStyle={styles.cancelLabel}>
CANCEL
</Button>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.content}>
{state === 'scanning' && (
<>
<View style={styles.scanIndicator}>
<ActivityIndicator
size="large"
color={DTColors.modeNormal}
style={styles.spinner}
/>
<View style={styles.pulseRing} />
</View>
<Text variant="headlineMedium" style={styles.scanningText}>
SCANNING
</Text>
<Text variant="bodyLarge" style={styles.instructionText}>
{getScanInstructions()}
</Text>
</>
)}
{state === 'error' && (
<>
<Text variant="headlineMedium" style={styles.errorText}>
SCAN FAILED
</Text>
<Text variant="bodyLarge" style={styles.errorMessage}>
{error?.message || 'Unable to read NFC tag'}
</Text>
{error?.type === 'TAG_LOST' && (
<Text variant="bodyMedium" style={styles.hintText}>
Hold the tag steady until the scan completes
</Text>
)}
<Button
mode="outlined"
onPress={handleRetry}
style={styles.retryButton}
labelStyle={styles.buttonLabel}>
TRY AGAIN
</Button>
</>
)}
{state === 'idle' && (
<>
<Text variant="headlineMedium" style={styles.readyText}>
READY TO SCAN
</Text>
<Button
mode="outlined"
onPress={startScan}
style={styles.startButton}
labelStyle={styles.buttonLabel}>
START
</Button>
</>
)}
{state === 'processing' && (
<>
<ActivityIndicator
size="large"
color={DTColors.modeEmphasis}
style={styles.processingSpinner}
/>
<Text variant="headlineMedium" style={styles.processingText}>
PROCESSING
</Text>
</>
)}
</View>
<View style={styles.footer}>
<Button
mode="text"
onPress={handleCancel}
labelStyle={styles.cancelLabel}>
CANCEL
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: DTColors.dark,
padding: 24,
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
scanIndicator: {
width: 150,
height: 150,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 40,
},
spinner: {
position: 'absolute',
},
pulseRing: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 2,
borderColor: DTColors.modeNormal,
opacity: 0.3,
},
scanningText: {
color: DTColors.modeNormal,
letterSpacing: 4,
marginBottom: 16,
},
instructionText: {
color: DTColors.light,
opacity: 0.8,
textAlign: 'center',
paddingHorizontal: 20,
},
errorText: {
color: DTColors.modeWarning,
letterSpacing: 2,
marginBottom: 16,
},
warningText: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 16,
},
errorMessage: {
color: DTColors.light,
opacity: 0.8,
textAlign: 'center',
marginBottom: 24,
paddingHorizontal: 20,
},
hintText: {
color: DTColors.modeNormal,
opacity: 0.7,
textAlign: 'center',
marginBottom: 24,
paddingHorizontal: 20,
fontStyle: 'italic',
},
retryButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
},
settingsButton: {
borderColor: DTColors.modeEmphasis,
borderWidth: 2,
marginTop: 16,
},
readyText: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 32,
},
startButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
},
buttonLabel: {
color: DTColors.modeNormal,
letterSpacing: 2,
},
processingSpinner: {
marginBottom: 24,
},
processingText: {
color: DTColors.modeEmphasis,
letterSpacing: 4,
},
footer: {
alignItems: 'center',
paddingBottom: 20,
},
cancelLabel: {
color: DTColors.light,
opacity: 0.6,
},
retryLabel: {
color: DTColors.modeNormal,
marginBottom: 8,
},
});

3
src/screens/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export {HomeScreen} from './HomeScreen';
export {ScanScreen} from './ScanScreen';
export {ResultScreen} from './ResultScreen';

View File

@@ -0,0 +1,295 @@
/**
* NFC Manager Service
* Wrapper around react-native-nfc-manager for cross-platform NFC operations
*/
import {Platform} from 'react-native';
import NfcManager, {NfcTech, TagEvent} from 'react-native-nfc-manager';
import type {
RawTagData,
NFCStatus,
ScanError,
ScanErrorType,
NfcTechType,
} from '../../types/nfc';
/**
* Convert byte array to hex string
*/
function bytesToHex(bytes: number[] | undefined): string {
if (!bytes || bytes.length === 0) {
return '';
}
return bytes.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
}
/**
* Parse SAK from tag event
*/
function parseSak(tag: TagEvent): number | undefined {
// Android provides nfcA.sak
const nfcA = (tag as any).nfcA;
if (nfcA?.sak !== undefined) {
return nfcA.sak;
}
return undefined;
}
/**
* Parse ATQA from tag event
*/
function parseAtqa(tag: TagEvent): string | undefined {
// Android provides nfcA.atqa as byte array
const nfcA = (tag as any).nfcA;
if (nfcA?.atqa) {
return bytesToHex(nfcA.atqa);
}
return undefined;
}
/**
* Parse ATS and historical bytes from tag event
*/
function parseAts(tag: TagEvent): {ats?: string; historicalBytes?: string} {
const isoDep = (tag as any).isoDep;
const iso7816 = (tag as any).iso7816;
let historicalBytes: string | undefined;
// Android: isoDep.historicalBytes
if (isoDep?.historicalBytes) {
historicalBytes = bytesToHex(isoDep.historicalBytes);
}
// iOS: iso7816.historicalBytes
if (iso7816?.historicalBytes) {
historicalBytes = bytesToHex(iso7816.historicalBytes);
}
// Construct ATS if we have historical bytes (simplified)
const ats = historicalBytes ? historicalBytes : undefined;
return {ats, historicalBytes};
}
/**
* Convert TagEvent to RawTagData
*/
function tagEventToRawData(tag: TagEvent): RawTagData {
const techTypes = (tag.techTypes || []) as NfcTechType[];
const uid = tag.id ? bytesToHex(tag.id as unknown as number[]) : '';
const sak = parseSak(tag);
const atqa = parseAtqa(tag);
const {ats, historicalBytes} = parseAts(tag);
const isoDep = (tag as any).isoDep;
const maxTransceiveLength = isoDep?.maxTransceiveLength;
return {
uid,
techTypes,
sak,
atqa,
ats,
historicalBytes,
maxTransceiveLength,
};
}
/**
* Create a structured scan error
*/
function createScanError(
type: ScanErrorType,
message: string,
originalError?: unknown,
): ScanError {
return {type, message, originalError};
}
/**
* Determine error type from error message/object
*/
function categorizeError(error: unknown): ScanError {
const errorMessage = error instanceof Error ? error.message : String(error);
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('cancelled') || lowerMessage.includes('canceled')) {
return createScanError('SCAN_CANCELLED', 'Scan was cancelled', error);
}
if (lowerMessage.includes('tag was lost') || lowerMessage.includes('taglost')) {
return createScanError('TAG_LOST', 'Tag was removed during scan', error);
}
if (lowerMessage.includes('timeout')) {
return createScanError('TIMEOUT', 'Scan timed out', error);
}
if (lowerMessage.includes('permission')) {
return createScanError('PERMISSION_DENIED', 'NFC permission denied', error);
}
if (lowerMessage.includes('not enabled') || lowerMessage.includes('disabled')) {
return createScanError('NFC_NOT_ENABLED', 'NFC is disabled on this device', error);
}
if (lowerMessage.includes('not supported')) {
return createScanError('NFC_NOT_SUPPORTED', 'NFC is not supported on this device', error);
}
return createScanError('UNKNOWN', errorMessage || 'An unknown NFC error occurred', error);
}
/**
* NFC Manager singleton class
*/
class NFCManagerService {
private initialized = false;
/**
* Initialize NFC manager
* Must be called before any other NFC operations
*/
async init(): Promise<boolean> {
if (this.initialized) {
return true;
}
try {
const supported = await NfcManager.isSupported();
if (!supported) {
return false;
}
await NfcManager.start();
this.initialized = true;
return true;
} catch (error) {
console.error('[NFCManager] Init failed:', error);
return false;
}
}
/**
* Check NFC status (supported and enabled)
*/
async getStatus(): Promise<NFCStatus> {
try {
const isSupported = await NfcManager.isSupported();
if (!isSupported) {
return {isSupported: false, isEnabled: false};
}
const isEnabled = await NfcManager.isEnabled();
return {isSupported, isEnabled};
} catch {
return {isSupported: false, isEnabled: false};
}
}
/**
* Request NFC technology and scan for a tag
* Returns raw tag data on success
*/
async scanTag(): Promise<{tag?: RawTagData; error?: ScanError}> {
try {
// Ensure initialized
if (!this.initialized) {
const initSuccess = await this.init();
if (!initSuccess) {
return {
error: createScanError(
'NFC_NOT_SUPPORTED',
'NFC is not supported on this device',
),
};
}
}
// Check if NFC is enabled
const status = await this.getStatus();
if (!status.isEnabled) {
return {
error: createScanError(
'NFC_NOT_ENABLED',
'Please enable NFC in your device settings',
),
};
}
// Request technology based on platform
if (Platform.OS === 'ios') {
// iOS: Use MifareIOS for broadest compatibility with ISO 14443 tags
await NfcManager.requestTechnology(NfcTech.MifareIOS, {
alertMessage: 'Hold your NFC tag near the top of your iPhone',
});
} else {
// Android: Request multiple technologies for best detection
await NfcManager.requestTechnology([
NfcTech.NfcA,
NfcTech.NfcB,
NfcTech.NfcV,
NfcTech.IsoDep,
NfcTech.MifareClassic,
]);
}
// Get the tag
const tag = await NfcManager.getTag();
if (!tag) {
return {
error: createScanError('UNKNOWN', 'No tag data received'),
};
}
return {tag: tagEventToRawData(tag)};
} catch (error) {
return {error: categorizeError(error)};
} finally {
// Always clean up
await this.cancelScan();
}
}
/**
* Cancel ongoing scan and release NFC technology
*/
async cancelScan(): Promise<void> {
try {
await NfcManager.cancelTechnologyRequest();
} catch {
// Ignore errors during cleanup
}
}
/**
* Clean up NFC manager
* Call when app is unmounting or NFC no longer needed
*/
async cleanup(): Promise<void> {
try {
await this.cancelScan();
// Note: We don't call NfcManager.stop() as it can cause issues
// if we need to restart scanning
} catch {
// Ignore cleanup errors
}
}
/**
* Open device NFC settings (Android only)
*/
async openNFCSettings(): Promise<void> {
if (Platform.OS === 'android') {
try {
await NfcManager.goToNfcSetting();
} catch {
// Settings may not be available
}
}
}
}
// Export singleton instance
export const nfcManager = new NFCManagerService();

View File

@@ -0,0 +1,2 @@
export {nfcManager} from './NFCManager';
export * from './platforms';

View File

@@ -0,0 +1,147 @@
/**
* Platform-specific NFC utilities
*/
import {Platform} from 'react-native';
import type {NfcTechType, RawTagData} from '../../types/nfc';
/**
* Check if the current platform is iOS
*/
export function isIOS(): boolean {
return Platform.OS === 'ios';
}
/**
* Check if the current platform is Android
*/
export function isAndroid(): boolean {
return Platform.OS === 'android';
}
/**
* Check if MIFARE Classic sector operations are available
* Only supported on Android
*/
export function supportsMifareClassicSectors(): boolean {
return isAndroid();
}
/**
* Check if raw NFC-A commands are available
* Full support on Android, limited on iOS
*/
export function supportsRawNfcACommands(): boolean {
return isAndroid();
}
/**
* Check if the tag supports ISO-DEP (ISO 14443-4)
*/
export function hasIsoDep(tag: RawTagData): boolean {
return tag.techTypes.includes('IsoDep') || tag.techTypes.includes('Iso7816');
}
/**
* Check if the tag is a MIFARE Classic (based on SAK)
* SAK 0x08 = MIFARE Classic 1K
* SAK 0x18 = MIFARE Classic 4K
* SAK 0x09 = MIFARE Mini
*/
export function isMifareClassicByTag(tag: RawTagData): boolean {
if (tag.sak === undefined) {
return false;
}
return tag.sak === 0x08 || tag.sak === 0x18 || tag.sak === 0x09;
}
/**
* Get the MIFARE Classic size from SAK
*/
export function getMifareClassicSize(sak: number): '320B' | '1K' | '4K' | null {
switch (sak) {
case 0x09:
return '320B'; // Mini
case 0x08:
return '1K';
case 0x18:
return '4K';
default:
return null;
}
}
/**
* Check if tag has NFC-A technology (ISO 14443-3A)
*/
export function hasNfcA(tag: RawTagData): boolean {
return tag.techTypes.includes('NfcA');
}
/**
* Check if tag has NFC-V technology (ISO 15693)
*/
export function hasNfcV(tag: RawTagData): boolean {
return tag.techTypes.includes('NfcV') || tag.techTypes.includes('Iso15693');
}
/**
* Get platform-specific user instructions for scanning
*/
export function getScanInstructions(): string {
if (isIOS()) {
return 'Hold your NFC tag near the top of your iPhone';
}
return 'Hold your NFC tag near the back of your device';
}
/**
* Get the primary NFC tech type to request based on detected technologies
*/
export function getPrimaryTech(techTypes: NfcTechType[]): NfcTechType | null {
// Prefer IsoDep/Iso7816 for smart cards
if (techTypes.includes('IsoDep')) {
return 'IsoDep';
}
if (techTypes.includes('Iso7816')) {
return 'Iso7816';
}
// Then NFC-A for most common tags
if (techTypes.includes('NfcA')) {
return 'NfcA';
}
// NFC-V for ISO 15693 tags
if (techTypes.includes('NfcV')) {
return 'NfcV';
}
// MIFARE specific
if (techTypes.includes('MifareClassic')) {
return 'MifareClassic';
}
if (techTypes.includes('MifareUltralight')) {
return 'MifareUltralight';
}
return techTypes[0] || null;
}
/**
* Platform limitations info
*/
export const PLATFORM_LIMITATIONS = {
ios: {
mifareClassicSectors: false,
rawNfcA: false,
backgroundReading: false,
description: 'iOS has limited NFC capabilities due to CoreNFC restrictions',
},
android: {
mifareClassicSectors: true,
rawNfcA: true,
backgroundReading: true,
description: 'Android has full NFC capabilities',
},
};

13
src/theme/colors.ts Normal file
View File

@@ -0,0 +1,13 @@
export const DTColors = {
dark: '#000000',
light: '#FFFFFF',
modeNormal: '#00FFFF', // Cyan - primary actions
modeNormalSelected: 'rgba(0, 255, 255, 0.7)',
modeEmphasis: '#FFFF00', // Yellow - highlights
modeEmphasisSelected: 'rgba(255, 255, 0, 0.7)',
modeWarning: '#FF0000', // Red - errors/warnings
modeSuccess: '#00FF00', // Green - success states
modeOther: '#FF00FF', // Magenta - misc
} as const;
export type DTColorKey = keyof typeof DTColors;

4
src/theme/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export {DTColors} from './colors';
export type {DTColorKey} from './colors';
export {DTFonts, DTTypography} from './typography';
export {DTTheme} from './paperTheme';

71
src/theme/paperTheme.ts Normal file
View File

@@ -0,0 +1,71 @@
import {MD3DarkTheme, configureFonts} from 'react-native-paper';
import type {MD3Theme} from 'react-native-paper';
import {DTColors} from './colors';
import {DTTypography} from './typography';
const fontConfig = {
displayLarge: DTTypography.displayLarge,
displayMedium: DTTypography.displayMedium,
displaySmall: DTTypography.displaySmall,
headlineLarge: DTTypography.headlineLarge,
headlineMedium: DTTypography.headlineMedium,
headlineSmall: DTTypography.headlineSmall,
titleLarge: DTTypography.titleLarge,
titleMedium: DTTypography.titleMedium,
titleSmall: DTTypography.titleSmall,
bodyLarge: DTTypography.bodyLarge,
bodyMedium: DTTypography.bodyMedium,
bodySmall: DTTypography.bodySmall,
labelLarge: DTTypography.labelLarge,
labelMedium: DTTypography.labelMedium,
labelSmall: DTTypography.labelSmall,
};
export const DTTheme: MD3Theme = {
...MD3DarkTheme,
dark: true,
colors: {
...MD3DarkTheme.colors,
primary: DTColors.modeNormal,
onPrimary: DTColors.dark,
primaryContainer: DTColors.modeNormalSelected,
onPrimaryContainer: DTColors.dark,
secondary: DTColors.modeEmphasis,
onSecondary: DTColors.dark,
secondaryContainer: DTColors.modeEmphasisSelected,
onSecondaryContainer: DTColors.dark,
tertiary: DTColors.modeOther,
onTertiary: DTColors.dark,
tertiaryContainer: 'rgba(255, 0, 255, 0.3)',
onTertiaryContainer: DTColors.light,
error: DTColors.modeWarning,
onError: DTColors.dark,
errorContainer: 'rgba(255, 0, 0, 0.3)',
onErrorContainer: DTColors.light,
background: DTColors.dark,
onBackground: DTColors.light,
surface: DTColors.dark,
onSurface: DTColors.light,
surfaceVariant: '#1a1a1a',
onSurfaceVariant: DTColors.light,
outline: DTColors.modeNormal,
outlineVariant: 'rgba(0, 255, 255, 0.3)',
inverseSurface: DTColors.light,
inverseOnSurface: DTColors.dark,
inversePrimary: '#008888',
elevation: {
level0: 'transparent',
level1: '#0a0a0a',
level2: '#121212',
level3: '#1a1a1a',
level4: '#1e1e1e',
level5: '#222222',
},
surfaceDisabled: 'rgba(255, 255, 255, 0.12)',
onSurfaceDisabled: 'rgba(255, 255, 255, 0.38)',
backdrop: 'rgba(0, 0, 0, 0.5)',
shadow: DTColors.dark,
scrim: DTColors.dark,
},
fonts: configureFonts({config: fontConfig}),
};

96
src/theme/typography.ts Normal file
View File

@@ -0,0 +1,96 @@
import {Platform} from 'react-native';
export const DTFonts = {
primary: Platform.select({
ios: 'Tektur',
android: 'Tektur',
default: 'System',
}),
} as const;
export const DTTypography = {
displayLarge: {
fontFamily: DTFonts.primary,
fontSize: 57,
fontWeight: '400' as const,
letterSpacing: -0.25,
},
displayMedium: {
fontFamily: DTFonts.primary,
fontSize: 45,
fontWeight: '400' as const,
},
displaySmall: {
fontFamily: DTFonts.primary,
fontSize: 36,
fontWeight: '400' as const,
},
headlineLarge: {
fontFamily: DTFonts.primary,
fontSize: 32,
fontWeight: '400' as const,
},
headlineMedium: {
fontFamily: DTFonts.primary,
fontSize: 28,
fontWeight: '400' as const,
},
headlineSmall: {
fontFamily: DTFonts.primary,
fontSize: 24,
fontWeight: '400' as const,
},
titleLarge: {
fontFamily: DTFonts.primary,
fontSize: 22,
fontWeight: '500' as const,
},
titleMedium: {
fontFamily: DTFonts.primary,
fontSize: 16,
fontWeight: '500' as const,
letterSpacing: 0.15,
},
titleSmall: {
fontFamily: DTFonts.primary,
fontSize: 14,
fontWeight: '500' as const,
letterSpacing: 0.1,
},
bodyLarge: {
fontFamily: DTFonts.primary,
fontSize: 16,
fontWeight: '400' as const,
letterSpacing: 0.5,
},
bodyMedium: {
fontFamily: DTFonts.primary,
fontSize: 14,
fontWeight: '400' as const,
letterSpacing: 0.25,
},
bodySmall: {
fontFamily: DTFonts.primary,
fontSize: 12,
fontWeight: '400' as const,
letterSpacing: 0.4,
},
labelLarge: {
fontFamily: DTFonts.primary,
fontSize: 14,
fontWeight: '500' as const,
letterSpacing: 0.1,
},
labelMedium: {
fontFamily: DTFonts.primary,
fontSize: 12,
fontWeight: '500' as const,
letterSpacing: 0.5,
},
labelSmall: {
fontFamily: DTFonts.primary,
fontSize: 11,
fontWeight: '500' as const,
letterSpacing: 0.5,
},
} as const;

32
src/types/navigation.ts Normal file
View File

@@ -0,0 +1,32 @@
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
import type {NfcTechType} from './nfc';
export type TagDataParam = {
uid: string;
techTypes: NfcTechType[];
sak?: number;
atqa?: string;
ats?: string;
historicalBytes?: string;
};
export type RootStackParamList = {
Home: undefined;
Scan: undefined;
Result: {
tagData?: TagDataParam;
};
};
export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
export type ScanScreenProps = NativeStackScreenProps<RootStackParamList, 'Scan'>;
export type ResultScreenProps = NativeStackScreenProps<
RootStackParamList,
'Result'
>;
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

82
src/types/nfc.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* NFC-related type definitions
*/
/**
* Supported NFC technology types from react-native-nfc-manager
*/
export type NfcTechType =
| 'NfcA'
| 'NfcB'
| 'NfcF'
| 'NfcV'
| 'IsoDep'
| 'Ndef'
| 'NdefFormatable'
| 'MifareClassic'
| 'MifareUltralight'
| 'Iso7816'
| 'Iso15693';
/**
* Raw tag data from NFC scan
*/
export interface RawTagData {
/** Tag UID as hex string (colon-separated) */
uid: string;
/** Available NFC technologies on this tag */
techTypes: NfcTechType[];
/** ATQA bytes (ISO 14443-3A) - Android only */
atqa?: string;
/** SAK byte (ISO 14443-3A) */
sak?: number;
/** ATS bytes (ISO 14443-4) */
ats?: string;
/** Historical bytes from ATS */
historicalBytes?: string;
/** Maximum transceive length */
maxTransceiveLength?: number;
}
/**
* Scan state machine states
*/
export type ScanState = 'idle' | 'scanning' | 'processing' | 'success' | 'error';
/**
* Scan result with raw tag data
*/
export interface ScanResult {
success: boolean;
tag?: RawTagData;
error?: ScanError;
}
/**
* NFC scan error types
*/
export type ScanErrorType =
| 'NFC_NOT_SUPPORTED'
| 'NFC_NOT_ENABLED'
| 'PERMISSION_DENIED'
| 'TAG_LOST'
| 'SCAN_CANCELLED'
| 'TIMEOUT'
| 'UNKNOWN';
/**
* Structured scan error
*/
export interface ScanError {
type: ScanErrorType;
message: string;
originalError?: unknown;
}
/**
* NFC manager status
*/
export interface NFCStatus {
isSupported: boolean;
isEnabled: boolean;
}