From 1fe37835ef3bc30190d58664f5eff7889f709833 Mon Sep 17 00:00:00 2001 From: iredis Date: Tue, 28 Jul 2026 19:11:33 +0100 Subject: [PATCH] feat(mobile): add QR scanner component using expo-camera Creates QrScanner component that replaces the web BarcodeDetector approach with expo-camera's CameraView + onBarcodeScanned for native mobile QR scanning. Includes camera permission handling, viewfinder overlay, and manual address entry fallback. Closes #468 --- frontend/mobile/app.json | 6 + frontend/mobile/components/QrScanner.tsx | 378 +++++++++++++++++++++++ frontend/mobile/package.json | 1 + 3 files changed, 385 insertions(+) create mode 100644 frontend/mobile/components/QrScanner.tsx diff --git a/frontend/mobile/app.json b/frontend/mobile/app.json index 415223e2..27810934 100644 --- a/frontend/mobile/app.json +++ b/frontend/mobile/app.json @@ -25,6 +25,12 @@ }, "plugins": [ "expo-router", + [ + "expo-camera", + { + "cameraPermission": "Veil needs camera access to scan QR codes." + } + ], [ "expo-splash-screen", { diff --git a/frontend/mobile/components/QrScanner.tsx b/frontend/mobile/components/QrScanner.tsx new file mode 100644 index 00000000..292d0903 --- /dev/null +++ b/frontend/mobile/components/QrScanner.tsx @@ -0,0 +1,378 @@ +import { useState, useCallback } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Modal, + Platform, +} from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; + +interface QrScannerProps { + visible: boolean; + onScan: (address: string) => void; + onClose: () => void; +} + +export function isValidStellarAddress(addr: string): boolean { + const v = addr.trim(); + return (v.startsWith('G') || v.startsWith('C')) && v.length === 56; +} + +export function QrScanner({ visible, onScan, onClose }: QrScannerProps) { + const [permission, requestPermission] = useCameraPermissions(); + const [manualAddress, setManualAddress] = useState(''); + const [manualError, setManualError] = useState(null); + const [scanEnabled, setScanEnabled] = useState(true); + + const isPermissionGranted = permission?.granted ?? false; + const isPermissionUndetermined = permission === null; + + const handleBarcodeScanned = useCallback( + ({ data }: { data: string }) => { + if (!scanEnabled) return; + const addr = data.trim(); + if (isValidStellarAddress(addr)) { + setScanEnabled(false); + onScan(addr); + } + }, + [scanEnabled, onScan] + ); + + const handleRequestPermission = useCallback(async () => { + await requestPermission(); + }, [requestPermission]); + + const handleManualSubmit = useCallback(() => { + const addr = manualAddress.trim(); + if (!isValidStellarAddress(addr)) { + setManualError( + 'Enter a valid Stellar address (G... or C..., 56 characters).' + ); + return; + } + setManualError(null); + onScan(addr); + }, [manualAddress, onScan]); + + // Reset scan throttle when modal opens + const handleModalShow = useCallback(() => { + setScanEnabled(true); + setManualAddress(''); + setManualError(null); + }, []); + + return ( + + + {/* Camera view */} + {isPermissionUndetermined || !isPermissionGranted ? ( + + Camera Access Required + + Veil needs camera access to scan Stellar QR codes for recipient + addresses. + + + + {isPermissionUndetermined + ? 'Grant Camera Access' + : 'Open Settings'} + + + + ) : ( + + {/* Viewfinder overlay */} + + + {(['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const).map( + (corner) => ( + + ) + )} + + + Point camera at a Stellar address QR code (G... or C...) + + + + )} + + {/* Overlay UI */} + + {/* Header */} + + Scan Recipient QR + + × + + + + + {/* Bottom sheet: manual address entry */} + + + + or type / paste address + + + + Stellar address + { + setManualAddress(text); + setManualError(null); + }} + placeholder="G... or C..." + placeholderTextColor="rgba(246,247,248,0.3)" + autoCorrect={false} + autoCapitalize="none" + spellCheck={false} + aria-describedby={manualError ? 'qr-manual-error' : undefined} + aria-invalid={manualError ? true : undefined} + /> + {manualError && ( + + {manualError} + + )} + + + Use this address + + + + Cancel + + + + + ); +} + +const CORNER_SIZE = 20; +const CORNER_THICKNESS = 3; +const CORNER_COLOR = '#D4AF37'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0B0B0F', + }, + permissionContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + gap: 16, + }, + permissionTitle: { + color: '#F6F7F8', + fontSize: 20, + fontWeight: '600', + fontFamily: 'Lora, Georgia, serif', + fontStyle: 'italic', + }, + permissionText: { + color: 'rgba(246,247,248,0.6)', + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, + permissionButton: { + backgroundColor: '#D4AF37', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + permissionButtonText: { + color: '#0B0B0F', + fontWeight: '600', + fontSize: 14, + }, + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + viewfinder: { + width: 220, + height: 220, + position: 'relative', + }, + corner: { + position: 'absolute', + width: CORNER_SIZE, + height: CORNER_SIZE, + }, + cornerTopLeft: { + top: 0, + left: 0, + borderTopWidth: CORNER_THICKNESS, + borderLeftWidth: CORNER_THICKNESS, + borderColor: CORNER_COLOR, + borderTopLeftRadius: 4, + }, + cornerTopRight: { + top: 0, + right: 0, + borderTopWidth: CORNER_THICKNESS, + borderRightWidth: CORNER_THICKNESS, + borderColor: CORNER_COLOR, + borderTopRightRadius: 4, + }, + cornerBottomLeft: { + bottom: 0, + left: 0, + borderBottomWidth: CORNER_THICKNESS, + borderLeftWidth: CORNER_THICKNESS, + borderColor: CORNER_COLOR, + borderBottomLeftRadius: 4, + }, + cornerBottomRight: { + bottom: 0, + right: 0, + borderBottomWidth: CORNER_THICKNESS, + borderRightWidth: CORNER_THICKNESS, + borderColor: CORNER_COLOR, + borderBottomRightRadius: 4, + }, + hint: { + color: 'rgba(246,247,248,0.5)', + fontSize: 12, + textAlign: 'center', + marginTop: 24, + paddingHorizontal: 32, + }, + uiOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + paddingTop: Platform.OS === 'ios' ? 60 : 40, + paddingHorizontal: 20, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + headerTitle: { + color: '#F6F7F8', + fontFamily: 'Lora, Georgia, serif', + fontWeight: '600', + fontStyle: 'italic', + fontSize: 18, + }, + closeButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(246,247,248,0.1)', + alignItems: 'center', + justifyContent: 'center', + }, + closeText: { + color: '#F6F7F8', + fontSize: 22, + lineHeight: 24, + }, + bottomSheet: { + paddingHorizontal: 24, + paddingBottom: Platform.OS === 'ios' ? 40 : 24, + paddingTop: 16, + gap: 10, + }, + dividerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginBottom: 4, + }, + divider: { + flex: 1, + height: 1, + backgroundColor: 'rgba(246,247,248,0.15)', + }, + dividerText: { + color: 'rgba(246,247,248,0.4)', + fontSize: 11, + }, + inputLabel: { + color: 'rgba(246,247,248,0.5)', + fontSize: 11, + marginBottom: -4, + }, + input: { + backgroundColor: 'rgba(246,247,248,0.06)', + borderWidth: 1, + borderColor: 'rgba(246,247,248,0.15)', + borderRadius: 8, + color: '#F6F7F8', + fontSize: 13, + fontFamily: 'Inconsolata, monospace', + padding: 12, + }, + inputError: { + borderColor: 'rgba(255,80,80,0.6)', + }, + errorText: { + color: 'rgba(255,100,100,0.9)', + fontSize: 12, + }, + actionButton: { + backgroundColor: 'rgba(212,175,55,0.15)', + borderWidth: 1, + borderColor: 'rgba(212,175,55,0.3)', + borderRadius: 8, + paddingVertical: 12, + alignItems: 'center', + }, + actionButtonText: { + color: '#D4AF37', + fontWeight: '600', + fontSize: 14, + }, + cancelButton: { + paddingVertical: 12, + alignItems: 'center', + }, + cancelButtonText: { + color: 'rgba(246,247,248,0.5)', + fontSize: 14, + }, +}); diff --git a/frontend/mobile/package.json b/frontend/mobile/package.json index 4bb69023..de2a6d0a 100644 --- a/frontend/mobile/package.json +++ b/frontend/mobile/package.json @@ -15,6 +15,7 @@ "@soroswap/sdk": "^0.4.0", "@stellar/stellar-sdk": "^14.6.1", "expo": "~57.0.8", + "expo-camera": "~17.0.0", "expo-constants": "~57.0.7", "expo-font": "~57.0.1", "expo-linking": "~57.0.4",