Initial commit
This commit is contained in:
2
src/hooks/index.ts
Normal file
2
src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {useScan} from './useScan';
|
||||
export type {UseScanResult} from './useScan';
|
||||
186
src/hooks/useScan.ts
Normal file
186
src/hooks/useScan.ts
Normal 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
100
src/screens/HomeScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
240
src/screens/ResultScreen.tsx
Normal file
240
src/screens/ResultScreen.tsx
Normal 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
312
src/screens/ScanScreen.tsx
Normal 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
3
src/screens/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export {HomeScreen} from './HomeScreen';
|
||||
export {ScanScreen} from './ScanScreen';
|
||||
export {ResultScreen} from './ResultScreen';
|
||||
295
src/services/nfc/NFCManager.ts
Normal file
295
src/services/nfc/NFCManager.ts
Normal 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();
|
||||
2
src/services/nfc/index.ts
Normal file
2
src/services/nfc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {nfcManager} from './NFCManager';
|
||||
export * from './platforms';
|
||||
147
src/services/nfc/platforms.ts
Normal file
147
src/services/nfc/platforms.ts
Normal 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
13
src/theme/colors.ts
Normal 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
4
src/theme/index.ts
Normal 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
71
src/theme/paperTheme.ts
Normal 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
96
src/theme/typography.ts
Normal 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
32
src/types/navigation.ts
Normal 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
82
src/types/nfc.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user