diff --git a/mobile/app.json b/mobile/app.json index 207c13f7..8ee32743 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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" } diff --git a/mobile/package.json b/mobile/package.json index e6f205a3..77e23cda 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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" + ] } } } diff --git a/mobile/src/components/wallet/QRScannerModal.tsx b/mobile/src/components/wallet/QRScannerModal.tsx new file mode 100644 index 00000000..c0dfec1d --- /dev/null +++ b/mobile/src/components/wallet/QRScannerModal.tsx @@ -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 = ({ + 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 ( + + + + {title} + + + QR Scanner is not available on web.{'\n'} + Please use the mobile app. + + + + Close + + + + + ); + } + + // Permission not granted yet + if (!permission) { + return ( + + + + + Requesting camera permission... + + + + ); + } + + // Permission denied + if (!permission.granted) { + return ( + + + + Camera Permission Required + + We need your permission to use the camera for scanning QR codes. + + + + Grant Permission + + + Cancel + + + + + + ); + } + + return ( + + + {/* Header */} + + + + + {title} + + + + {/* Camera View */} + + + + {/* Loading Indicator */} + {isLoading && ( + + + Starting camera... + + )} + + {/* Scanner Overlay */} + + {/* Top overlay */} + + + {/* Middle row with scanner frame */} + + + + {/* Corner indicators */} + + + + + + + + + {/* Bottom overlay */} + + {subtitle} + {scanned && ( + setScanned(false)} + > + Tap to Scan Again + + )} + + + + + + ); +}; + +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; diff --git a/mobile/src/screens/WalletScreen.tsx b/mobile/src/screens/WalletScreen.tsx index 0ce5572a..b2fb5703 100644 --- a/mobile/src/screens/WalletScreen.tsx +++ b/mobile/src/screens/WalletScreen.tsx @@ -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 = () => { showAlert('Scan', 'QR Scanner coming soon')} + onPress={() => setQrScannerVisible(true)} testID="wallet-scan-button" > @@ -1031,6 +1081,15 @@ const WalletScreen: React.FC = () => { onTokenAdded={fetchData} /> + {/* QR Scanner Modal */} + setQrScannerVisible(false)} + onScan={handleQRScan} + title="Scan Address" + subtitle="Scan a wallet address QR code to send funds" + /> + {/* Address Book Modal */} setAddressBookVisible(false)}>