feat(mobile): implement QR code scanner for wallet addresses

- Add expo-camera package for QR scanning
- Create QRScannerModal component with camera permission handling
- Integrate scanner into WalletScreen scan button
- Support substrate: and pezkuwi: URI formats with amount parameter
- Add address validation before opening send modal
- Configure camera permissions for iOS and Android in app.json
This commit is contained in:
2026-01-15 10:59:39 +03:00
parent 7c0f963dce
commit 1c86e3cf53
4 changed files with 510 additions and 4 deletions
+14 -2
View File
@@ -13,7 +13,10 @@
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Pezkuwi needs camera access to scan QR codes for wallet addresses and payments."
}
},
"android": {
"package": "io.pezkuwichain.wallet",
@@ -22,8 +25,17 @@
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"permissions": ["android.permission.CAMERA"]
},
"plugins": [
[
"expo-camera",
{
"cameraPermission": "Pezkuwi needs camera access to scan QR codes for wallet addresses and payments."
}
]
],
"web": {
"favicon": "./assets/favicon.png"
}
+4 -1
View File
@@ -45,6 +45,7 @@
"@supabase/supabase-js": "^2.90.1",
"buffer": "^6.0.3",
"expo": "~54.0.23",
"expo-camera": "~17.0.10",
"expo-image-picker": "~17.0.10",
"expo-linear-gradient": "^15.0.7",
"expo-local-authentication": "^17.0.7",
@@ -113,7 +114,9 @@
"private": true,
"expo": {
"install": {
"exclude": ["@types/react"]
"exclude": [
"@types/react"
]
}
}
}
@@ -0,0 +1,432 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
Platform,
Dimensions,
ActivityIndicator,
} from 'react-native';
import { CameraView, useCameraPermissions, BarcodeScanningResult } from 'expo-camera';
import { KurdistanColors } from '../../theme/colors';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SCANNER_SIZE = SCREEN_WIDTH * 0.7;
interface QRScannerModalProps {
visible: boolean;
onClose: () => void;
onScan: (data: string) => void;
title?: string;
subtitle?: string;
}
export const QRScannerModal: React.FC<QRScannerModalProps> = ({
visible,
onClose,
onScan,
title = 'Scan QR Code',
subtitle = 'Position the QR code within the frame',
}) => {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (visible) {
setScanned(false);
setIsLoading(true);
// Request permission when modal opens
if (!permission?.granted) {
requestPermission();
}
}
}, [visible, permission, requestPermission]);
const handleBarCodeScanned = (result: BarcodeScanningResult) => {
if (scanned) return;
setScanned(true);
const { data } = result;
// Call the onScan callback with the scanned data
onScan(data);
onClose();
};
const handleCameraReady = () => {
setIsLoading(false);
};
// Web platform fallback
if (Platform.OS === 'web') {
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Text style={styles.title}>{title}</Text>
<View style={styles.webFallback}>
<Text style={styles.webFallbackText}>
QR Scanner is not available on web.{'\n'}
Please use the mobile app.
</Text>
</View>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
// Permission not granted yet
if (!permission) {
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
<Text style={styles.permissionText}>Requesting camera permission...</Text>
</View>
</View>
</Modal>
);
}
// Permission denied
if (!permission.granted) {
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Text style={styles.title}>Camera Permission Required</Text>
<Text style={styles.permissionText}>
We need your permission to use the camera for scanning QR codes.
</Text>
<View style={styles.buttonRow}>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}>Grant Permission</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.permissionButton, styles.cancelButton]} onPress={onClose}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
return (
<Modal
visible={visible}
animationType="slide"
transparent={false}
onRequestClose={onClose}
>
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={onClose}>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
<Text style={styles.headerTitle}>{title}</Text>
<View style={styles.placeholder} />
</View>
{/* Camera View */}
<View style={styles.cameraContainer}>
<CameraView
style={StyleSheet.absoluteFillObject}
facing="back"
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
onCameraReady={handleCameraReady}
/>
{/* Loading Indicator */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.loadingText}>Starting camera...</Text>
</View>
)}
{/* Scanner Overlay */}
<View style={styles.overlay}>
{/* Top overlay */}
<View style={styles.overlayTop} />
{/* Middle row with scanner frame */}
<View style={styles.overlayMiddle}>
<View style={styles.overlaySide} />
<View style={styles.scannerFrame}>
{/* Corner indicators */}
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<View style={styles.overlaySide} />
</View>
{/* Bottom overlay */}
<View style={styles.overlayBottom}>
<Text style={styles.subtitle}>{subtitle}</Text>
{scanned && (
<TouchableOpacity
style={styles.rescanButton}
onPress={() => setScanned(false)}
>
<Text style={styles.rescanButtonText}>Tap to Scan Again</Text>
</TouchableOpacity>
)}
</View>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'ios' ? 50 : 20,
paddingBottom: 16,
backgroundColor: 'rgba(0,0,0,0.7)',
zIndex: 10,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center',
alignItems: 'center',
},
backButtonText: {
color: '#fff',
fontSize: 20,
fontWeight: 'bold',
},
headerTitle: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
placeholder: {
width: 40,
},
cameraContainer: {
flex: 1,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.8)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
loadingText: {
color: '#fff',
marginTop: 12,
fontSize: 16,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
overlayTop: {
flex: 1,
width: '100%',
backgroundColor: 'rgba(0,0,0,0.6)',
},
overlayMiddle: {
flexDirection: 'row',
height: SCANNER_SIZE,
},
overlaySide: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
},
scannerFrame: {
width: SCANNER_SIZE,
height: SCANNER_SIZE,
borderWidth: 2,
borderColor: 'transparent',
},
corner: {
position: 'absolute',
width: 30,
height: 30,
borderColor: KurdistanColors.kesk,
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 4,
borderLeftWidth: 4,
borderTopLeftRadius: 8,
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 4,
borderRightWidth: 4,
borderTopRightRadius: 8,
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 4,
borderLeftWidth: 4,
borderBottomLeftRadius: 8,
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 4,
borderRightWidth: 4,
borderBottomRightRadius: 8,
},
overlayBottom: {
flex: 1,
width: '100%',
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center',
paddingTop: 30,
},
subtitle: {
color: '#fff',
fontSize: 16,
textAlign: 'center',
paddingHorizontal: 20,
},
rescanButton: {
marginTop: 20,
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: KurdistanColors.kesk,
borderRadius: 8,
},
rescanButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
// Permission modal styles
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalView: {
margin: 20,
backgroundColor: '#1a1a2e',
borderRadius: 20,
padding: 35,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
width: SCREEN_WIDTH * 0.85,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
marginBottom: 15,
textAlign: 'center',
},
permissionText: {
color: '#aaa',
fontSize: 16,
textAlign: 'center',
marginBottom: 20,
lineHeight: 24,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
},
permissionButton: {
backgroundColor: KurdistanColors.kesk,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
permissionButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
cancelButton: {
backgroundColor: '#333',
},
cancelButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
closeButton: {
marginTop: 20,
paddingHorizontal: 30,
paddingVertical: 12,
backgroundColor: '#333',
borderRadius: 8,
},
closeButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
webFallback: {
padding: 30,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 12,
marginVertical: 20,
},
webFallbackText: {
color: '#aaa',
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
},
});
export default QRScannerModal;
+60 -1
View File
@@ -25,6 +25,7 @@ import * as SecureStore from 'expo-secure-store';
import { KurdistanColors } from '../theme/colors';
import { usePezkuwi, NetworkType, NETWORKS } from '../contexts/PezkuwiContext';
import { AddTokenModal } from '../components/wallet/AddTokenModal';
import { QRScannerModal } from '../components/wallet/QRScannerModal';
import { HezTokenLogo, PezTokenLogo } from '../components/icons';
import { decodeAddress, checkAddress, encodeAddress } from '@pezkuwi/util-crypto';
@@ -119,6 +120,7 @@ const WalletScreen: React.FC = () => {
const [networkSelectorVisible, setNetworkSelectorVisible] = useState(false);
const [walletSelectorVisible, setWalletSelectorVisible] = useState(false);
const [addTokenModalVisible, setAddTokenModalVisible] = useState(false);
const [qrScannerVisible, setQrScannerVisible] = useState(false);
const [tokenSearchVisible, setTokenSearchVisible] = useState(false);
const [tokenSearchQuery, setTokenSearchQuery] = useState('');
const [tokenSettingsVisible, setTokenSettingsVisible] = useState(false);
@@ -309,6 +311,54 @@ const WalletScreen: React.FC = () => {
setReceiveModalVisible(true);
};
// Handle QR code scan result
const handleQRScan = (data: string) => {
// Try to parse the scanned data
let address = data;
let amount: string | undefined;
// Check if it's a Pezkuwi/Substrate URI format (e.g., "substrate:ADDRESS?amount=10")
if (data.startsWith('substrate:') || data.startsWith('pezkuwi:')) {
const uri = data.replace(/^(substrate:|pezkuwi:)/, '');
const [addr, params] = uri.split('?');
address = addr;
if (params) {
const urlParams = new URLSearchParams(params);
amount = urlParams.get('amount') || undefined;
}
}
// Validate the address
try {
const [isValid] = checkAddress(address, 42); // 42 is the SS58 prefix for Pezkuwi
if (!isValid) {
// Try with generic prefix
const [isValidGeneric] = checkAddress(address, -1);
if (!isValidGeneric) {
showAlert('Invalid QR Code', 'The scanned QR code does not contain a valid Pezkuwi address.');
return;
}
}
} catch {
showAlert('Invalid QR Code', 'The scanned QR code does not contain a valid address.');
return;
}
// Open send modal with the scanned address
setRecipientAddress(address);
if (amount) {
setSendAmount(amount);
}
setSelectedToken(tokens[0]); // Default to HEZ
setSendModalVisible(true);
// Show success feedback
if (Platform.OS !== 'web') {
Alert.alert('Address Scanned', `Address: ${address.slice(0, 8)}...${address.slice(-6)}${amount ? `\nAmount: ${amount}` : ''}`);
}
};
// Load saved addresses from storage
useEffect(() => {
const loadAddressBook = async () => {
@@ -646,7 +696,7 @@ const WalletScreen: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
style={styles.scanButton}
onPress={() => showAlert('Scan', 'QR Scanner coming soon')}
onPress={() => setQrScannerVisible(true)}
testID="wallet-scan-button"
>
<Text style={styles.scanIcon}></Text>
@@ -1031,6 +1081,15 @@ const WalletScreen: React.FC = () => {
onTokenAdded={fetchData}
/>
{/* QR Scanner Modal */}
<QRScannerModal
visible={qrScannerVisible}
onClose={() => setQrScannerVisible(false)}
onScan={handleQRScan}
title="Scan Address"
subtitle="Scan a wallet address QR code to send funds"
/>
{/* Address Book Modal */}
<Modal visible={addressBookVisible} transparent animationType="slide" onRequestClose={() => setAddressBookVisible(false)}>
<View style={styles.modalOverlay}>