mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 04:27:56 +00:00
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:
+14
-2
@@ -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
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user