Initial commit
This commit is contained in:
4
.eslintrc.js
Normal file
4
.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: '@react-native',
|
||||
};
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native directories (generated by expo prebuild)
|
||||
android/
|
||||
ios/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Build
|
||||
*.jsbundle
|
||||
77
App.tsx
Normal file
77
App.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import {StatusBar} from 'react-native';
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context';
|
||||
import {PaperProvider} from 'react-native-paper';
|
||||
import {NavigationContainer, DefaultTheme} from '@react-navigation/native';
|
||||
import {createNativeStackNavigator} from '@react-navigation/native-stack';
|
||||
|
||||
import {DTTheme, DTColors} from './src/theme';
|
||||
import {HomeScreen, ScanScreen, ResultScreen} from './src/screens';
|
||||
import type {RootStackParamList} from './src/types/navigation';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
const NavigationTheme = {
|
||||
...DefaultTheme,
|
||||
dark: true,
|
||||
colors: {
|
||||
...DefaultTheme.colors,
|
||||
primary: DTColors.modeNormal,
|
||||
background: DTColors.dark,
|
||||
card: DTColors.dark,
|
||||
text: DTColors.light,
|
||||
border: DTColors.modeNormal,
|
||||
notification: DTColors.modeEmphasis,
|
||||
},
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<PaperProvider theme={DTTheme}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={DTColors.dark} />
|
||||
<NavigationContainer theme={NavigationTheme}>
|
||||
<Stack.Navigator
|
||||
initialRouteName="Home"
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: DTColors.dark,
|
||||
},
|
||||
headerTintColor: DTColors.modeNormal,
|
||||
headerTitleStyle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: DTColors.dark,
|
||||
},
|
||||
animation: 'slide_from_right',
|
||||
}}>
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{headerShown: false}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Scan"
|
||||
component={ScanScreen}
|
||||
options={{
|
||||
title: 'SCAN',
|
||||
headerBackTitle: 'Back',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Result"
|
||||
component={ResultScreen}
|
||||
options={{
|
||||
title: 'RESULT',
|
||||
headerBackTitle: 'Scan',
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
362
CLAUDE.md
Normal file
362
CLAUDE.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# CLAUDE.md - Project Intelligence for Dangerous Things NFC Identifier
|
||||
|
||||
## Project Overview
|
||||
|
||||
React Native app that scans NFC transponders and matches them to Dangerous Things implant products. Target users are prospective customers who want to know which implant can replace their existing cards/fobs.
|
||||
|
||||
## Critical Context
|
||||
|
||||
### NFC Detection Strategy
|
||||
|
||||
The app must identify transponders using a **waterfall detection approach**:
|
||||
|
||||
```
|
||||
1. Read basic tag info (UID, ATQA, SAK for 14443-A)
|
||||
2. Determine tag technology class
|
||||
3. Run technology-specific identification:
|
||||
- NTAG: Send GET_VERSION (0x60) command
|
||||
- MIFARE Classic: SAK identifies 1K (0x08) vs 4K (0x18)
|
||||
- ISO 14443-4: Parse ATS/historical bytes, then:
|
||||
- DESFire: Check for DESFire ATS signature
|
||||
- MIFARE Plus: Check for Plus ATS signature
|
||||
- JavaCard: GET DATA for CPLC, probe known AIDs
|
||||
- ISO 15693: Get system info for SLIX identification
|
||||
```
|
||||
|
||||
### Platform Differences
|
||||
|
||||
**Android** - Full access via `NfcA`, `NfcB`, `IsoDep`, `NfcV`, `MifareClassic` tech classes. Can send raw commands.
|
||||
|
||||
**iOS** - CoreNFC with `NFCISO7816Tag` and `NFCISO15693Tag`. Provides:
|
||||
- `historicalBytes` (from ATS) - use for card platform identification
|
||||
- `identifier` (UID)
|
||||
- APDU commands via `sendCommand()`
|
||||
- NO access to MIFARE Classic sectors (no crypto support)
|
||||
|
||||
**iOS MIFARE Classic limitation**: iOS cannot read MIFARE Classic as a distinct technology. It appears as an ISO 14443-3A tag. Detection relies on SAK value (0x08 or 0x18) but sector-level operations are Android-only.
|
||||
|
||||
### JavaCard (J3R180) Detection
|
||||
|
||||
For JCOP4/J3R180 identification:
|
||||
1. Check `historicalBytes` for JCOP signatures (`4A434F50` = "JCOP")
|
||||
2. GET DATA for CPLC: `80 CA 9F 7F 00`
|
||||
3. Parse CPLC for IC fabricator (NXP = 4790), card type, OS ID
|
||||
4. Probe AIDs for installed applets (user may provide DT-specific AIDs)
|
||||
|
||||
### Product Matching Logic
|
||||
|
||||
```typescript
|
||||
interface Transponder {
|
||||
type: ChipType;
|
||||
subtype?: string; // e.g., "NTAG215" vs "NTAG216"
|
||||
memorySize?: number;
|
||||
isCloneable: boolean;
|
||||
rawData: {
|
||||
uid: string;
|
||||
sak?: number;
|
||||
atqa?: string;
|
||||
ats?: string;
|
||||
historicalBytes?: string;
|
||||
cplc?: CPLCData;
|
||||
};
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
compatibleChips: ChipType[];
|
||||
description: string;
|
||||
features: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Cloneable chips**:
|
||||
- NTAG213/215/216 (data can be written to compatible implant)
|
||||
- MIFARE Classic 1K/4K (requires key knowledge; Android only for sector ops)
|
||||
- SLIX/SLIX2 (data can be written)
|
||||
|
||||
**Non-cloneable**: DESFire, MIFARE Plus, JavaCard (crypto prevents duplication)
|
||||
|
||||
When no match found, direct to: `dngr.us/conversion`
|
||||
|
||||
## Design System
|
||||
|
||||
Base theme on `/home/work/WebstormProjects/dt-shopify-storefront/app/styles/app.css`
|
||||
|
||||
### Colors (CSS variables → RN)
|
||||
```typescript
|
||||
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
|
||||
};
|
||||
```
|
||||
|
||||
### Typography
|
||||
- Primary font: "Tektur" (variable font, weights 100-900)
|
||||
- Fallback: System default
|
||||
|
||||
### UI Patterns
|
||||
- **Beveled corners**: Use `clip-path` equivalent or custom shapes
|
||||
- **Cards**: Black background, colored borders, beveled bottom-right
|
||||
- **Buttons**: Outlined with mode color, filled on hover/press
|
||||
- **Emphasis**: Yellow for important actions, cyan for standard
|
||||
|
||||
## Tech Stack Decisions
|
||||
|
||||
| Concern | Choice | Rationale |
|
||||
|---------|--------|-----------|
|
||||
| NFC | react-native-nfc-manager | Industry standard, good iOS/Android support |
|
||||
| UI | React Native Paper | Material Design 3, easy theming |
|
||||
| State | React Context | Simple app, no complex state needs |
|
||||
| Navigation | React Navigation | Standard choice |
|
||||
| Types | TypeScript strict | Prevent NFC data handling bugs |
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.tsx # Entry point, providers
|
||||
├── components/
|
||||
│ ├── ui/ # Themed RNP components
|
||||
│ │ ├── DTCard.tsx
|
||||
│ │ ├── DTButton.tsx
|
||||
│ │ └── DTChip.tsx
|
||||
│ ├── scan/ # Scan-related components
|
||||
│ │ ├── ScanButton.tsx
|
||||
│ │ └── ScanAnimation.tsx
|
||||
│ └── results/ # Result display components
|
||||
│ ├── TransponderInfo.tsx
|
||||
│ ├── ProductMatch.tsx
|
||||
│ └── NoMatchFound.tsx
|
||||
├── screens/
|
||||
│ ├── HomeScreen.tsx
|
||||
│ ├── ScanScreen.tsx
|
||||
│ └── ResultScreen.tsx
|
||||
├── services/
|
||||
│ ├── nfc/
|
||||
│ │ ├── NFCManager.ts # Wrapper around react-native-nfc-manager
|
||||
│ │ ├── commands.ts # APDU command builders
|
||||
│ │ └── platforms.ts # Platform-specific logic
|
||||
│ ├── detection/
|
||||
│ │ ├── detector.ts # Main detection orchestrator
|
||||
│ │ ├── ntag.ts # NTAG identification
|
||||
│ │ ├── mifare.ts # MIFARE Classic/Plus/DESFire
|
||||
│ │ ├── iso15693.ts # SLIX detection
|
||||
│ │ └── javacard.ts # J3R180/JCOP detection
|
||||
│ └── matching/
|
||||
│ └── matcher.ts # Product matching logic
|
||||
├── data/
|
||||
│ ├── products.ts # Hardcoded product catalog
|
||||
│ └── chipProfiles.ts # Chip identification signatures
|
||||
├── theme/
|
||||
│ ├── colors.ts
|
||||
│ ├── typography.ts
|
||||
│ └── paperTheme.ts # RNP theme configuration
|
||||
├── types/
|
||||
│ ├── nfc.ts # NFC-related types
|
||||
│ ├── products.ts # Product types
|
||||
│ └── detection.ts # Detection result types
|
||||
├── hooks/
|
||||
│ ├── useNFC.ts
|
||||
│ └── useScan.ts
|
||||
└── utils/
|
||||
├── hex.ts # Hex string utilities
|
||||
├── apdu.ts # APDU parsing utilities
|
||||
└── platform.ts # Platform detection
|
||||
```
|
||||
|
||||
## Development Phases (Context-Optimized)
|
||||
|
||||
### Phase 1: Foundation (Single Session)
|
||||
**Goal**: Bootable app with theme and navigation
|
||||
- Initialize RN project with TypeScript
|
||||
- Install dependencies (RNP, react-native-nfc-manager, navigation)
|
||||
- Create theme system from DT colors
|
||||
- Set up basic navigation (Home → Scan → Results)
|
||||
- Create placeholder screens
|
||||
|
||||
**Deliverable**: App runs on both platforms with DT styling
|
||||
|
||||
### Phase 2: NFC Infrastructure (Single Session)
|
||||
**Goal**: NFC scanning works on both platforms
|
||||
- Implement NFCManager wrapper
|
||||
- Handle permissions (Android manifest, iOS entitlements)
|
||||
- Create useScan hook with states (idle, scanning, success, error)
|
||||
- Test basic tag detection (just read UID)
|
||||
|
||||
**Deliverable**: App can detect NFC tag presence
|
||||
|
||||
### Phase 3: Chip Detection - Basic (Single Session)
|
||||
**Goal**: Identify common chip types
|
||||
- Implement NTAG detection (GET_VERSION)
|
||||
- Implement MIFARE Classic detection (SAK-based)
|
||||
- Create detection result types
|
||||
- Display raw detection results
|
||||
|
||||
**Deliverable**: App identifies NTAG and MIFARE Classic
|
||||
|
||||
### Phase 4: Chip Detection - Advanced (Single Session)
|
||||
**Goal**: Full chip identification suite
|
||||
- Implement ISO 14443-4 detection (DESFire, Plus)
|
||||
- Implement ISO 15693 detection (SLIX)
|
||||
- Implement JavaCard detection (CPLC + AID probing)
|
||||
- Handle platform differences gracefully
|
||||
|
||||
**Deliverable**: App identifies all target chip types
|
||||
|
||||
### Phase 5: Product Matching (Single Session)
|
||||
**Goal**: Match transponders to products
|
||||
- Create product data structure
|
||||
- Populate with DT product catalog
|
||||
- Implement matching algorithm
|
||||
- Create result UI components
|
||||
- Add "no match" handling with conversion link
|
||||
|
||||
**Deliverable**: Full transponder-to-implant flow complete
|
||||
|
||||
### Phase 6: Polish (Single Session)
|
||||
**Goal**: Production-ready UX
|
||||
- Add scan animations
|
||||
- Implement error handling UI
|
||||
- Add educational chip info
|
||||
- Test edge cases
|
||||
- Performance optimization
|
||||
|
||||
**Deliverable**: App ready for release
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### APDU Command Structure
|
||||
```typescript
|
||||
// Command APDU: CLA INS P1 P2 [Lc] [Data] [Le]
|
||||
const GET_CPLC = [0x80, 0xCA, 0x9F, 0x7F, 0x00];
|
||||
const SELECT_AID = (aid: number[]) => [0x00, 0xA4, 0x04, 0x00, aid.length, ...aid, 0x00];
|
||||
|
||||
// DESFire GET_VERSION (ISO-wrapped for cross-platform support)
|
||||
// Response byte 3 = HW major: 0x00=EV0, 0x01=EV1, 0x10=EV2, 0x30=EV3
|
||||
const DESFIRE_GET_VERSION = [0x90, 0x60, 0x00, 0x00, 0x00];
|
||||
```
|
||||
|
||||
### Platform-Safe NFC Code
|
||||
```typescript
|
||||
import { Platform } from 'react-native';
|
||||
import NfcManager, { NfcTech } from 'react-native-nfc-manager';
|
||||
|
||||
async function detectTag() {
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS: Use ISO 7816 for everything 14443-4
|
||||
await NfcManager.requestTechnology(NfcTech.Iso7816);
|
||||
} else {
|
||||
// Android: Can be more specific
|
||||
await NfcManager.requestTechnology([
|
||||
NfcTech.IsoDep,
|
||||
NfcTech.NfcA,
|
||||
NfcTech.MifareClassic,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Boundaries for NFC
|
||||
Always wrap NFC operations in try/catch. Common errors:
|
||||
- Tag lost during read
|
||||
- Unsupported tag type
|
||||
- Permission denied
|
||||
- NFC disabled on device
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Physical Test Cards Needed
|
||||
- NTAG213, NTAG215, NTAG216
|
||||
- MIFARE Classic 1K, 4K
|
||||
- MIFARE DESFire EV1/EV2/EV3
|
||||
- MIFARE Plus
|
||||
- SLIX/SLIX2
|
||||
- J3R180/JCOP4 card
|
||||
|
||||
### Simulator Limitations
|
||||
NFC cannot be tested in simulators. Use physical devices only.
|
||||
|
||||
## Known Issues & Workarounds
|
||||
|
||||
1. **iOS MIFARE Classic**: Cannot do sector operations. Only detect via SAK, inform user of Android requirement for cloning.
|
||||
|
||||
2. **iOS Background Reading**: Not supported. App must be foregrounded.
|
||||
|
||||
3. **Android NFC Intent Handling**: May need to handle `onNewIntent` for tags scanned while app is open.
|
||||
|
||||
4. **react-native-nfc-manager quirks**: Always call `NfcManager.cancelTechnologyRequest()` in finally blocks.
|
||||
|
||||
## Reference Links
|
||||
|
||||
- [react-native-nfc-manager docs](https://github.com/revtel/react-native-nfc-manager)
|
||||
- [NXP MIFARE product selector](https://www.nxp.com/products/rfid-nfc/mifare-hf:MIFARE)
|
||||
- [ISO 14443 overview](https://www.iso.org/standard/73599.html)
|
||||
- [JCOP CPLC parsing](https://www.openscdp.org/scripts/tutorial/emv/CPLC.html)
|
||||
- [Dangerous Things conversion service](https://dngr.us/conversion)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm start # Start Metro bundler
|
||||
npm run android # Run on Android
|
||||
npm run ios # Run on iOS
|
||||
|
||||
# Type checking
|
||||
npm run typecheck # Run TypeScript compiler
|
||||
|
||||
# Linting
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # ESLint with auto-fix
|
||||
|
||||
# Testing (when tests exist)
|
||||
npm test # Run Jest tests
|
||||
```
|
||||
|
||||
## Environment Setup Checklist
|
||||
|
||||
### Android
|
||||
- [ ] `android/app/src/main/AndroidManifest.xml` has NFC permissions
|
||||
- [ ] Min SDK 21+ (for robust NFC support)
|
||||
- [ ] NFC intent filters configured
|
||||
|
||||
### iOS
|
||||
- [ ] NFC capability added in Xcode
|
||||
- [ ] `NFCReaderUsageDescription` in Info.plist
|
||||
- [ ] `com.apple.developer.nfc.readersession.iso7816.select-identifiers` for AID selection
|
||||
- [ ] `com.apple.developer.nfc.readersession.iso15693.select-identifiers` for SLIX
|
||||
|
||||
## AID Reference (For JavaCard Profiling)
|
||||
|
||||
Dangerous Things may provide specific AIDs for their applets. Common AIDs:
|
||||
```typescript
|
||||
const KNOWN_AIDS = {
|
||||
// Global Platform Card Manager
|
||||
cardManager: [0xA0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00],
|
||||
|
||||
// VivoKey-specific (placeholder - get actual AIDs from DT)
|
||||
// vivoKeyAuth: [...],
|
||||
// vivoKeyOTP: [...],
|
||||
|
||||
// Standard applets
|
||||
openPGP: [0xD2, 0x76, 0x00, 0x01, 0x24, 0x01],
|
||||
fido: [0xA0, 0x00, 0x00, 0x06, 0x47, 0x2F, 0x00, 0x01],
|
||||
};
|
||||
```
|
||||
|
||||
## Conversation Starters for Future Sessions
|
||||
|
||||
When resuming work, start with:
|
||||
1. "Continue from Phase N of the roadmap"
|
||||
2. "The current blocker is..."
|
||||
3. "Need to implement [specific detector/component]"
|
||||
|
||||
Always check `package.json` and run `npm install` if dependencies seem missing.
|
||||
16
Gemfile
Normal file
16
Gemfile
Normal file
@@ -0,0 +1,16 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
||||
ruby ">= 2.6.10"
|
||||
|
||||
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||
gem 'xcodeproj', '< 1.26.0'
|
||||
gem 'concurrent-ruby', '< 1.3.4'
|
||||
|
||||
# Ruby 3.4.0 has removed some libraries from the standard library.
|
||||
gem 'bigdecimal'
|
||||
gem 'logger'
|
||||
gem 'benchmark'
|
||||
gem 'mutex_m'
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
|
||||
|
||||
# Getting Started
|
||||
|
||||
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
|
||||
|
||||
## Step 1: Start Metro
|
||||
|
||||
First, you will need to run **Metro**, the JavaScript build tool for React Native.
|
||||
|
||||
To start the Metro dev server, run the following command from the root of your React Native project:
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm start
|
||||
|
||||
# OR using Yarn
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Step 2: Build and run your app
|
||||
|
||||
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
|
||||
|
||||
### Android
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run android
|
||||
|
||||
# OR using Yarn
|
||||
yarn android
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
|
||||
|
||||
The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
```
|
||||
|
||||
Then, and every time you update your native dependencies, run:
|
||||
|
||||
```sh
|
||||
bundle exec pod install
|
||||
```
|
||||
|
||||
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run ios
|
||||
|
||||
# OR using Yarn
|
||||
yarn ios
|
||||
```
|
||||
|
||||
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
|
||||
|
||||
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
|
||||
|
||||
## Step 3: Modify your app
|
||||
|
||||
Now that you have successfully run the app, let's make changes!
|
||||
|
||||
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
|
||||
|
||||
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
|
||||
|
||||
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
|
||||
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
|
||||
|
||||
## Congratulations! :tada:
|
||||
|
||||
You've successfully run and modified your React Native App. :partying_face:
|
||||
|
||||
### Now what?
|
||||
|
||||
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
|
||||
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
|
||||
|
||||
# Learn More
|
||||
|
||||
To learn more about React Native, take a look at the following resources:
|
||||
|
||||
- [React Native Website](https://reactnative.dev) - learn more about React Native.
|
||||
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
|
||||
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
|
||||
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
|
||||
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.
|
||||
307
ROADMAP.md
Normal file
307
ROADMAP.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Development Roadmap
|
||||
|
||||
This roadmap is optimized for AI-assisted development with context window efficiency in mind. Each phase is designed to be **completable in a single session** without requiring extensive context from previous phases.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Self-contained phases** - Each phase has clear inputs/outputs
|
||||
2. **Minimal cross-phase dependencies** - Read file structure, not conversation history
|
||||
3. **Testable milestones** - Each phase ends with something verifiable
|
||||
4. **Progressive complexity** - Simple foundations before complex detection
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Project Bootstrap
|
||||
|
||||
**Session prompt**: "Initialize the React Native project with TypeScript, React Native Paper, and the Dangerous Things theme system."
|
||||
|
||||
### Inputs
|
||||
- None (greenfield)
|
||||
|
||||
### Tasks
|
||||
1. Initialize Expo project with TypeScript template
|
||||
2. Install core dependencies:
|
||||
- `@anthropic-dangerous-things/react-native-theme` (local link or npm)
|
||||
- `react-native-paper`
|
||||
- `react-native-safe-area-context`
|
||||
- `@react-navigation/native` + `@react-navigation/native-stack`
|
||||
- `react-native-nfc-manager`
|
||||
- `expo-font` (for Tektur)
|
||||
3. Link the DT theme package (`../react-native-dt-theme`)
|
||||
4. Use DTThemeProvider from the theme package
|
||||
- `colors.ts` - DT color palette
|
||||
- `typography.ts` - Tektur font setup
|
||||
- `paperTheme.ts` - React Native Paper theme config
|
||||
4. Create basic navigation structure:
|
||||
- `HomeScreen` - Mode selection (just "Transponder to Implant" for now)
|
||||
- `ScanScreen` - Placeholder
|
||||
- `ResultScreen` - Placeholder
|
||||
5. Wrap app with providers (Paper, Navigation, SafeArea)
|
||||
|
||||
### Outputs
|
||||
- Runnable app on both platforms
|
||||
- DT-themed UI with working navigation
|
||||
- No NFC functionality yet
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
npm run android # App launches with cyan/yellow theme
|
||||
npm run ios # App launches with cyan/yellow theme
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: NFC Infrastructure
|
||||
|
||||
**Session prompt**: "Implement the NFC service layer with platform permissions and the useScan hook."
|
||||
|
||||
### Inputs
|
||||
- Phase 1 complete (check for `src/theme/paperTheme.ts`)
|
||||
|
||||
### Tasks
|
||||
1. Configure Android NFC permissions:
|
||||
- `AndroidManifest.xml` - NFC permission + intent filters
|
||||
- Min SDK verification (21+)
|
||||
2. Configure iOS NFC entitlements:
|
||||
- Add NFC capability in Xcode
|
||||
- `Info.plist` - Usage description + AID identifiers
|
||||
3. Create NFC service layer (`src/services/nfc/`):
|
||||
- `NFCManager.ts` - Wrapper with init/cleanup lifecycle
|
||||
- `platforms.ts` - Platform detection utilities
|
||||
4. Create scan hook (`src/hooks/useScan.ts`):
|
||||
- States: `idle`, `scanning`, `success`, `error`
|
||||
- Handle tag discovered callback
|
||||
- Proper cleanup on unmount
|
||||
5. Update `ScanScreen` to use hook and display raw tag data
|
||||
|
||||
### Outputs
|
||||
- NFC permissions working on both platforms
|
||||
- Can detect any NFC tag and show UID
|
||||
- Proper scan lifecycle management
|
||||
|
||||
### Verification
|
||||
- Scan any NFC tag → UID displayed
|
||||
- Cancel scan → returns to idle state
|
||||
- Error states shown for NFC disabled/permission denied
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Basic Chip Detection
|
||||
|
||||
**Session prompt**: "Implement NTAG and MIFARE Classic detection with the chip identification framework."
|
||||
|
||||
### Inputs
|
||||
- Phase 2 complete (check for `src/hooks/useScan.ts`)
|
||||
|
||||
### Tasks
|
||||
1. Define detection types (`src/types/detection.ts`):
|
||||
```typescript
|
||||
interface DetectionResult {
|
||||
chipType: ChipType;
|
||||
chipSubtype?: string;
|
||||
isCloneable: boolean;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
rawData: RawTagData;
|
||||
}
|
||||
```
|
||||
2. Create detection framework (`src/services/detection/`):
|
||||
- `detector.ts` - Main orchestrator
|
||||
- `types.ts` - Chip type enums
|
||||
3. Implement NTAG detection (`src/services/detection/ntag.ts`):
|
||||
- Send GET_VERSION (0x60)
|
||||
- Parse response for 213/215/216/I2C variants
|
||||
- Mark as cloneable
|
||||
4. Implement MIFARE Classic detection (`src/services/detection/mifare.ts`):
|
||||
- Check SAK: 0x08 (1K), 0x18 (4K), 0x09 (Mini)
|
||||
- Mark as cloneable
|
||||
- Note Android-only for sector operations
|
||||
5. Update `ScanScreen` to show detection results
|
||||
|
||||
### Outputs
|
||||
- NTAG213/215/216 correctly identified
|
||||
- MIFARE Classic 1K/4K correctly identified
|
||||
- Detection results displayed with chip info
|
||||
|
||||
### Verification
|
||||
- Scan NTAG215 → Shows "NTAG215, Cloneable: Yes"
|
||||
- Scan MIFARE Classic 1K → Shows "MIFARE Classic 1K, Cloneable: Yes"
|
||||
- Scan other tags → Shows "Unknown" with raw data
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Advanced Chip Detection
|
||||
|
||||
**Session prompt**: "Add detection for DESFire, MIFARE Plus, SLIX, and JavaCard chips."
|
||||
|
||||
### Inputs
|
||||
- Phase 3 complete (check for `src/services/detection/ntag.ts`)
|
||||
|
||||
### Tasks
|
||||
1. Implement ISO 14443-4 detection (`src/services/detection/iso14443.ts`):
|
||||
- Parse ATS/historical bytes
|
||||
- Route to specific detectors based on signatures
|
||||
2. Implement DESFire detection (`src/services/detection/desfire.ts`):
|
||||
- Identify via ATS signature
|
||||
- Send GET_VERSION for EV1/EV2/EV3 determination
|
||||
- Mark as non-cloneable
|
||||
3. Implement MIFARE Plus detection:
|
||||
- Security level detection if possible
|
||||
- Mark as non-cloneable
|
||||
4. Implement ISO 15693 detection (`src/services/detection/iso15693.ts`):
|
||||
- Get system info command
|
||||
- Identify SLIX/SLIX2 variants
|
||||
- Mark as cloneable
|
||||
5. Implement JavaCard detection (`src/services/detection/javacard.ts`):
|
||||
- Parse historical bytes for JCOP signature
|
||||
- GET DATA for CPLC
|
||||
- AID probing for installed applets
|
||||
- Mark as non-cloneable
|
||||
6. Handle platform differences gracefully:
|
||||
- iOS: Use ISO 7816 wrapped commands
|
||||
- Android: Use native tech classes
|
||||
|
||||
### Outputs
|
||||
- All target chip types detected
|
||||
- DESFire version (EV1/2/3) identified
|
||||
- JavaCard identification via CPLC
|
||||
- Platform-appropriate detection paths
|
||||
|
||||
### Verification
|
||||
- Scan DESFire EV2 → Shows "MIFARE DESFire EV2, Cloneable: No"
|
||||
- Scan SLIX → Shows "SLIX, Cloneable: Yes"
|
||||
- Scan J3R180 → Shows "JCOP4 (J3R180), Cloneable: No"
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Product Matching
|
||||
|
||||
**Session prompt**: "Create the product catalog and matching algorithm, then build the results UI."
|
||||
|
||||
### Inputs
|
||||
- Phase 4 complete (check for `src/services/detection/javacard.ts`)
|
||||
|
||||
### Tasks
|
||||
1. Define product types (`src/types/products.ts`):
|
||||
```typescript
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
compatibleChips: ChipType[];
|
||||
description: string;
|
||||
features: string[];
|
||||
formFactor: 'x-series' | 'flex' | 'other';
|
||||
}
|
||||
```
|
||||
2. Create product catalog (`src/data/products.ts`):
|
||||
- xNT (NTAG216)
|
||||
- xEM (T5577 - for EM/HID cloning)
|
||||
- NExT (NTAG216 + T5577)
|
||||
- xSIID (NTAG I2C + LED)
|
||||
- xDF2 (DESFire EV2)
|
||||
- flexDF2 (DESFire EV2 flex)
|
||||
- xMagic (MIFARE Classic + Gen2 Magic)
|
||||
- Apex (J3R180)
|
||||
- Apex Flex (J3R180 flex)
|
||||
- SLIX implants if available
|
||||
3. Implement matching algorithm (`src/services/matching/matcher.ts`):
|
||||
- Match by chip type
|
||||
- Rank by feature parity
|
||||
- Handle cloneable vs native matches
|
||||
4. Create result components (`src/components/results/`):
|
||||
- `TransponderInfo.tsx` - Detected chip details
|
||||
- `ProductMatch.tsx` - Matching product card
|
||||
- `NoMatchFound.tsx` - Conversion service link
|
||||
5. Update `ResultScreen` with full flow
|
||||
|
||||
### Outputs
|
||||
- Complete transponder-to-implant matching
|
||||
- Product cards with descriptions
|
||||
- "No match" redirects to dngr.us/conversion
|
||||
|
||||
### Verification
|
||||
- Scan NTAG215 → Shows xNT, NExT as matches
|
||||
- Scan MIFARE Classic → Shows xMagic as match
|
||||
- Scan unknown chip → Shows conversion link
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & UX
|
||||
|
||||
**Session prompt**: "Add animations, error handling, and final UI polish for production readiness."
|
||||
|
||||
### Inputs
|
||||
- Phase 5 complete (check for `src/services/matching/matcher.ts`)
|
||||
|
||||
### Tasks
|
||||
1. Add scan animations:
|
||||
- Pulsing scan indicator
|
||||
- Success/failure transitions
|
||||
- Card detection feedback
|
||||
2. Implement comprehensive error handling:
|
||||
- NFC disabled state
|
||||
- Permission denied flow
|
||||
- Tag lost during scan
|
||||
- Unsupported tag graceful fallback
|
||||
3. Add educational content:
|
||||
- Chip type explanations
|
||||
- Cloneability explanations
|
||||
- "Why can't this be cloned?" info
|
||||
4. UI refinements:
|
||||
- Beveled corner components (DT style)
|
||||
- Proper loading states
|
||||
- Empty states
|
||||
5. Platform testing:
|
||||
- Test all chip types on Android
|
||||
- Test all chip types on iOS (within CoreNFC limits)
|
||||
- Edge case handling
|
||||
|
||||
### Outputs
|
||||
- Production-ready UX
|
||||
- Helpful error messages
|
||||
- Educational chip information
|
||||
- Consistent DT branding throughout
|
||||
|
||||
### Verification
|
||||
- Full flow feels polished
|
||||
- Errors are helpful, not cryptic
|
||||
- Works offline
|
||||
- Matches DT website aesthetic
|
||||
|
||||
---
|
||||
|
||||
## Future Phases (Post-MVP)
|
||||
|
||||
### Phase 7: "ID an Implant" Mode
|
||||
*Details TBD with employer*
|
||||
|
||||
### Phase 8: Scan History
|
||||
- Local storage of past scans
|
||||
- Favorites/bookmarks
|
||||
|
||||
### Phase 9: Store Integration
|
||||
- Direct product links (when store is live)
|
||||
- Pricing display
|
||||
|
||||
---
|
||||
|
||||
## Session Recovery Guide
|
||||
|
||||
If starting a new session mid-project:
|
||||
|
||||
1. **Check current state**:
|
||||
```bash
|
||||
ls src/services/detection/ # Which detectors exist?
|
||||
ls src/data/ # Product data present?
|
||||
```
|
||||
|
||||
2. **Identify phase**:
|
||||
- No `src/` → Start Phase 1
|
||||
- No `useScan.ts` → Start Phase 2
|
||||
- No `ntag.ts` → Start Phase 3
|
||||
- No `javacard.ts` → Start Phase 4
|
||||
- No `products.ts` → Start Phase 5
|
||||
- All present → Phase 6 or done
|
||||
|
||||
3. **Read CLAUDE.md** for technical context
|
||||
|
||||
4. **Use phase-specific prompt** from this document
|
||||
13
__tests__/App.test.tsx
Normal file
13
__tests__/App.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactTestRenderer from 'react-test-renderer';
|
||||
import App from '../App';
|
||||
|
||||
test('renders correctly', async () => {
|
||||
await ReactTestRenderer.act(() => {
|
||||
ReactTestRenderer.create(<App />);
|
||||
});
|
||||
});
|
||||
42
app.json
Normal file
42
app.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "DT NFC Identifier",
|
||||
"slug": "dt-nfc-identifier",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.dangerousthings.nfcidentifier",
|
||||
"infoPlist": {
|
||||
"NFCReaderUsageDescription": "This app uses NFC to scan transponders and identify compatible Dangerous Things implants."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"package": "com.dangerousthings.nfcidentifier",
|
||||
"permissions": ["android.permission.NFC"]
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"react-native-nfc-manager",
|
||||
{
|
||||
"nfcPermission": "This app uses NFC to scan transponders and identify compatible Dangerous Things implants.",
|
||||
"selectIdentifiers": [
|
||||
"A0000000030000",
|
||||
"D27600012401",
|
||||
"A0000006472F0001"
|
||||
],
|
||||
"systemCodes": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
6
babel.config.js
Normal file
6
babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
4
index.js
Normal file
4
index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import {registerRootComponent} from 'expo';
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
3
jest.config.js
Normal file
3
jest.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
};
|
||||
5
metro.config.js
Normal file
5
metro.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const {getDefaultConfig} = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = config;
|
||||
15199
package-lock.json
generated
Normal file
15199
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "dt-nfc-identifier",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"prebuild": "expo prebuild",
|
||||
"prebuild:clean": "expo prebuild --clean",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-navigation/native": "^7.1.27",
|
||||
"@react-navigation/native-stack": "^7.9.1",
|
||||
"babel-preset-expo": "^54.0.9",
|
||||
"expo": "^54.0.31",
|
||||
"expo-dev-client": "^6.0.20",
|
||||
"expo-status-bar": "^3.0.9",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.1",
|
||||
"react-native-nfc-manager": "^3.17.2",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-vector-icons": "^10.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native/babel-preset": "0.83.1",
|
||||
"@react-native/eslint-config": "0.83.1",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.6.3",
|
||||
"prettier": "2.8.8",
|
||||
"react-test-renderer": "19.2.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["jest"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||
"exclude": ["node_modules", "android", "ios"]
|
||||
}
|
||||
Reference in New Issue
Block a user