import React, { useEffect, useState, useRef } from "react"; import { View, Text, Modal, Platform, ActivityIndicator, TouchableOpacity, } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Button } from "~/components/ui/button"; import { PhonePinKeypad } from "~/components/ui/PhonePinKeypad"; import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile"; import { useUserProfile } from "~/lib/hooks/useUserProfile"; import { router } from "expo-router"; import { ROUTES } from "~/lib/routes"; import BackButton from "./backButton"; import { Fingerprint, Lock } from "lucide-react-native"; import ModalToast from "~/components/ui/toast"; // Only import LocalAuthentication on native platforms let LocalAuthentication: any = null; if (Platform.OS !== "web") { LocalAuthentication = require("expo-local-authentication"); } import { useTranslation } from "react-i18next"; interface PinConfirmationModalProps { visible: boolean; onClose: () => void; onSuccess: () => void; title?: string; } export const PinConfirmationModal: React.FC = ({ visible, onClose, onSuccess, title, }) => { const { t } = useTranslation(); const effectiveTitle = title ?? t("components.pinconfirmationmodal.titleDefault"); const { user } = useAuthWithProfile(); const { profile } = useUserProfile(user); const [pin, setPin] = useState(""); const [isVerifying, setIsVerifying] = useState(false); const [isAttemptingBiometric, setIsAttemptingBiometric] = useState(false); const [biometricAttempted, setBiometricAttempted] = useState(false); const [authMethod, setAuthMethod] = useState<"none" | "fingerprint" | "pin">( "none" ); const [biometricAvailable, setBiometricAvailable] = useState(false); const prevVisibleRef = useRef(false); const [toastVisible, setToastVisible] = useState(false); const [toastTitle, setToastTitle] = useState(""); const [toastDescription, setToastDescription] = useState( undefined ); const toastTimeoutRef = useRef | null>(null); const showToast = (title: string, description?: string) => { if (toastTimeoutRef.current) { clearTimeout(toastTimeoutRef.current); } setToastTitle(title); setToastDescription(description); setToastVisible(true); toastTimeoutRef.current = setTimeout(() => { setToastVisible(false); toastTimeoutRef.current = null; }, 2500); }; const attemptBiometric = async () => { // Web: No biometric support if (Platform.OS === "web" || !LocalAuthentication) { showToast("Error", "Biometric authentication not available on web"); setAuthMethod("none"); return; } if (!user) return; try { const hasHardware = await LocalAuthentication.hasHardwareAsync(); if (!hasHardware) { showToast( t("components.pinconfirmationmodal.toastBiometricErrorTitle"), t( "components.pinconfirmationmodal.toastBiometricHardwareNotAvailable" ) ); setAuthMethod("none"); return; } const isEnrolled = await LocalAuthentication.isEnrolledAsync(); if (!isEnrolled) { showToast( t("components.pinconfirmationmodal.toastBiometricErrorTitle"), t("components.pinconfirmationmodal.toastBiometricNotEnrolled") ); setAuthMethod("none"); return; } setIsAttemptingBiometric(true); const result = await LocalAuthentication.authenticateAsync({ promptMessage: effectiveTitle, cancelLabel: t("components.pinconfirmationmodal.cancelButton"), disableDeviceFallback: true, requireConfirmation: false, }); setIsAttemptingBiometric(false); if (result.success) { setPin(""); setBiometricAttempted(false); setAuthMethod("none"); onSuccess(); } else { // User cancelled or failed - reset to choice screen setAuthMethod("none"); } } catch (e) { setIsAttemptingBiometric(false); setAuthMethod("none"); showToast( t("components.pinconfirmationmodal.toastBiometricErrorTitle"), t("components.pinconfirmationmodal.toastBiometricFailed") ); } }; const handleFingerprintChoice = () => { setAuthMethod("fingerprint"); attemptBiometric(); }; const handlePinChoice = () => { setAuthMethod("pin"); }; // Check biometric availability on mount and when modal opens useEffect(() => { const checkBiometricAvailability = async () => { // Web: Biometrics not available if (Platform.OS === "web" || !LocalAuthentication) { setBiometricAvailable(false); return; } // Native: Check for biometric hardware and enrollment if (Platform.OS === "android" || Platform.OS === "ios") { try { const hasHardware = await LocalAuthentication.hasHardwareAsync(); const isEnrolled = await LocalAuthentication.isEnrolledAsync(); setBiometricAvailable(hasHardware && isEnrolled); } catch (e) { setBiometricAvailable(false); } } else { setBiometricAvailable(false); } }; if (visible) { checkBiometricAvailability(); } }, [visible]); useEffect(() => { const prevVisible = prevVisibleRef.current; prevVisibleRef.current = visible; if (visible && !prevVisible) { // Modal just opened (transition from false to true) // Reset all states to ensure fresh start setPin(""); setIsAttemptingBiometric(false); setBiometricAttempted(false); setAuthMethod("none"); } else if (!visible && prevVisible) { // Modal just closed (transition from true to false) // Reset all states for next time setPin(""); setIsAttemptingBiometric(false); setBiometricAttempted(false); setAuthMethod("none"); } }, [visible]); // Render nothing when not requested if (!visible) { return null; } const handlePinSubmit = async () => { if (pin.length !== 6) { showToast( t("components.pinconfirmationmodal.toastInvalidPinTitle"), t("components.pinconfirmationmodal.toastInvalidPinDescription") ); return; } setIsVerifying(true); if (!user) { showToast( t("components.pinconfirmationmodal.toastAuthErrorTitle"), t("components.pinconfirmationmodal.toastUserNotFound") ); setIsVerifying(false); return; } const profilePin = profile?.pin; if (!profilePin) { showToast( t("components.pinconfirmationmodal.toastAuthErrorTitle"), t("components.pinconfirmationmodal.toastPinNotFound") ); setIsVerifying(false); return; } // Incorrect PIN -> show inline toast instead of Alert if (pin !== profilePin) { showToast( t("components.pinconfirmationmodal.toastIncorrectPinTitle"), t("components.pinconfirmationmodal.toastIncorrectPinDescription") ); setPin(""); setIsVerifying(false); return; } // PIN is correct, call success callback setPin(""); setIsVerifying(false); setBiometricAttempted(false); setIsAttemptingBiometric(false); onSuccess(); }; const handleNumberPress = (input: string) => { if (input === "clear") { setPin(""); return; } if (input === "backspace") { handleBackspace(); return; } // Handle digit input (0-9) if (!/^[0-9]$/.test(input)) return; // Limit to 6 digits if (pin.length >= 6) return; setPin(pin + input); }; const handleBackspace = () => { setPin((prev) => prev.slice(0, -1)); }; const handleClose = () => { setPin(""); setIsVerifying(false); setIsAttemptingBiometric(false); setBiometricAttempted(false); setAuthMethod("none"); onClose(); }; const renderAuthMethodChoice = () => { // On web, skip biometric option entirely and go straight to PIN const showBiometricOption = biometricAvailable && Platform.OS !== "web"; return ( {showBiometricOption ? "Choose Authentication Method" : "PIN Verification"} {effectiveTitle} {showBiometricOption && ( {t("components.pinconfirmationmodal.fingerprintTitle")} {t("components.pinconfirmationmodal.fingerprintSubtitle")} )} {showBiometricOption && } {t("components.pinconfirmationmodal.pinTitle")} {t("components.pinconfirmationmodal.pinSubtitle")} ); }; const renderPinDots = () => { return ( {[0, 1, 2, 3, 4, 5].map((index) => ( ))} ); }; return ( { if (authMethod === "pin" || authMethod === "fingerprint") { setAuthMethod("none"); setPin(""); setIsAttemptingBiometric(false); } else { handleClose(); router.replace(ROUTES.HOME); } }} /> {authMethod === "none" ? ( {renderAuthMethodChoice()} ) : isAttemptingBiometric ? ( {t("components.pinconfirmationmodal.biometricWaiting")} { setAuthMethod("none"); setIsAttemptingBiometric(false); }} className="px-6 py-3" > {t("components.pinconfirmationmodal.cancelButton")} ) : ( {/* Header + title */} {t("components.pinconfirmationmodal.pinVerificationTitle")} {effectiveTitle} {/* Bottom-aligned PIN area */} {/* PIN dots + Keypad - full width above button */} {renderPinDots()} {/* Submit Button - just above bottom bar */} )} {/* Toast overlay above everything */} ); };