diff --git a/frontend/mobile/app/(tabs)/send.tsx b/frontend/mobile/app/(tabs)/send.tsx index 55557be..db32820 100644 --- a/frontend/mobile/app/(tabs)/send.tsx +++ b/frontend/mobile/app/(tabs)/send.tsx @@ -4,6 +4,7 @@ import { Pressable, SafeAreaView, StyleSheet, Text, TextInput, View } from 'reac import { isValidDestination } from '../../lib/address'; import { ContactPicker } from '../../components/ContactPicker'; +import { QrScanner } from '../../components/QrScanner'; import type { Contact } from '../../hooks/useContacts'; /** expo-router yields `string | string[]` for a repeated query key. */ @@ -28,6 +29,7 @@ export default function SendScreen() { const [recipient, setRecipient] = useState(() => firstValue(params.to)); const [amount, setAmount] = useState(() => firstValue(params.amount)); const [pickerOpen, setPickerOpen] = useState(false); + const [scannerOpen, setScannerOpen] = useState(false); const trimmed = recipient.trim(); const recipientValid = isValidDestination(trimmed); @@ -47,13 +49,23 @@ export default function SendScreen() { RECIPIENT - setPickerOpen(true)} - hitSlop={8} - > - Choose contact - + + setScannerOpen(true)} + hitSlop={8} + > + Scan QR + + setPickerOpen(true)} + hitSlop={8} + > + Choose contact + + + { + setRecipient(address); + setScannerOpen(false); + }} + onClose={() => setScannerOpen(false)} + /> + void; + onClose: () => void; +} + +/** + * Pull a destination out of a scanned value. Accepts a bare G…/C… address or a + * SEP-7 `web+stellar:pay?...` URI — #468 requires both, and payment-request QR + * codes are the SEP-7 form. Returns null for anything unrecognised so the + * camera keeps scanning rather than latching onto junk. + */ +function destinationFromScan(value: string): string | null { + const parsed = parseQrValue(value); + const destination = parsed && 'destination' in parsed ? parsed.destination : undefined; + if (destination && isValidStellarAddress(destination)) return destination; + + const trimmed = value.trim(); + return isValidStellarAddress(trimmed) ? trimmed : null; +} + +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 = destinationFromScan(data); + if (addr) { + setScanEnabled(false); + onScan(addr); + } + }, + [scanEnabled, onScan] + ); + + const handleRequestPermission = useCallback(async () => { + await requestPermission(); + }, [requestPermission]); + + const handleManualSubmit = useCallback(() => { + const addr = destinationFromScan(manualAddress); + if (!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, + }, +});