import React from 'react'; import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; import { X } from './icons'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors, useResolvedTheme } from '../theme/colors'; import Button from './Button'; import { useLocaleStore } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; interface QrScanModalProps { visible: boolean; onClose: () => void; // Fires once with the raw decoded QR string. The parent is responsible for // closing the modal and interpreting the payload. onScanned: (data: string) => void; } export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { const c = useColors(); const theme = useResolvedTheme(); const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]); const t = useLocaleStore((st) => st.t); const [permission, requestPermission] = useCameraPermissions(); // Guards against the camera firing onBarcodeScanned dozens of times for the // same code before the modal tears down. const handled = React.useRef(false); // Once iOS/Android stops letting us re-prompt, the only path left is Settings. const blocked = !!permission && !permission.granted && !permission.canAskAgain; React.useEffect(() => { if (visible) handled.current = false; }, [visible]); const handleBarcode = React.useCallback( (result: BarcodeScanningResult) => { if (handled.current) return; handled.current = true; onScanned(result.data); }, [onScanned], ); return ( {permission?.granted ? ( ) : null} {permission?.granted ? ( // Dims everything except the scan square. Four scrim regions leave a // transparent hole so the camera shows through only inside the frame. {t('login.mobile.qr_hint_title', 'Open Bulwark on your computer')} {t('login.mobile.qr_hint', 'Settings → Security → Link Mobile App shows a code. Point the camera at it.')} ) : null} {t('login.mobile.qr_title', 'Sign-in code')} {!permission?.granted ? ( {blocked ? t('login.mobile.camera_disabled', 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.') : t('login.mobile.camera_needed', 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.')} ) : null} ); } function makeStyles(c: ThemePalette, theme: 'light' | 'dark') { const scrimColor = theme === 'light' ? 'rgba(248,250,252,0.72)' : 'rgba(0,0,0,0.6)'; return StyleSheet.create({ container: { flex: 1, backgroundColor: '#000000' }, overlay: { flex: 1, justifyContent: 'flex-start' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.lg, paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm, paddingBottom: spacing.md, }, closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, title: { ...typography.h3, color: c.text }, scrim: { flex: 1, backgroundColor: scrimColor }, scrimCenterRow: { flexDirection: 'row', height: 240 }, scrimSide: { flex: 1, backgroundColor: scrimColor }, scrimBottom: { alignItems: 'center', paddingTop: spacing.xl }, frame: { width: 240, height: 240, borderWidth: 3, borderColor: c.text, borderRadius: radius.lg, backgroundColor: 'transparent', }, hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl }, hintTitle: { ...typography.bodySemibold, color: c.text, textAlign: 'center' }, hint: { ...typography.caption, color: c.textSecondary, textAlign: 'center', }, permissionWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, paddingHorizontal: spacing.xxl, backgroundColor: c.background, }, permissionText: { ...typography.body, color: c.text, textAlign: 'center' }, }); }