import React, { useCallback, useEffect, useRef, useState } from "react"; import { type LayoutChangeEvent, Platform, Pressable, StyleSheet, Text, View } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; import * as Haptics from "expo-haptics"; import { useColorScheme } from "nativewind"; import { Delete, Fingerprint } from "lucide-react-native"; /** * Numeric PIN entry pad with a filled-dots indicator. Theme-driven (uses * the active palette via NativeWind classes). Self-manages the entered * digits and fires `onComplete` when `length` is reached; pass a changing * `resetKey` to clear it (e.g. after a wrong PIN). * * # Sizing * * The pad fills the width it is given instead of sitting at a fixed 288px. On a * modern phone a fixed pad reads as a small control marooned in the middle of a * large screen — and this is the primary way into the app for a device-only * vault, so it should feel like the screen's purpose rather than a widget on it. * Keys grow with the available width and stop at `MAX_KEY` so a tablet does not * get comedy-sized buttons. * * The width comes from the pad's own `onLayout`, not from the viewport. It is * usually inside a card inside a padded screen, and every one of those layers * takes width the window knows nothing about — sizing off the window overflowed * the card it sat in. * * # How the keys read * * A hairline ring at rest and nothing else: no fill, mono digits, the same * quiet treatment the rest of the product uses. The ring is what makes a target * a target — an earlier pass filled the keys as well, and that was too heavy * beside everything around it. * * The press is where the weight goes instead: the ring brightens, a fill fades * in under it, and the key dips in scale. Three layered responses because the * resting state is deliberately understated, and "did that register?" is the * entire question somebody entering a PIN is asking. */ export interface PinPadProps { length?: number; onComplete: (pin: string) => void; onChange?: (pin: string) => void; /** Show a biometric key (bottom-left); omit to leave that slot blank. */ onBiometric?: () => void; /** Bump to clear the entered digits. */ resetKey?: string | number; error?: boolean; /** * Haptic feedback on each key. On by default, and a no-op on web and on any * device without a haptic engine. Pass `false` where a press already triggers * feedback of its own. */ haptics?: boolean; } const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const; /** Key diameter bounds, and the gap between them. */ const MIN_KEY = 76; const MAX_KEY = 104; const GAP = 16; /** Assumed width for the single frame before the first measurement lands. */ const ASSUMED_WIDTH = 320; export function PinPad({ length = 6, onComplete, onChange, onBiometric, resetKey, error, haptics = true, }: PinPadProps) { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const iconColor = isDark ? "#FFFFFF" : "#000000"; const [available, setAvailable] = useState(0); const onMeasure = (event: LayoutChangeEvent) => { const measured = event.nativeEvent.layout.width; if (measured > 0 && Math.abs(measured - available) > 1) { setAvailable(measured); } }; // Three keys and two gaps have to fit in the width this pad was actually // given. Clamped at both ends: MIN_KEY keeps the pad usable in a narrow // column even if that means it overflows a little, MAX_KEY stops a tablet // from rendering three dinner plates. const width = available || ASSUMED_WIDTH; const keySize = Math.max(MIN_KEY, Math.min(MAX_KEY, Math.floor((width - 2 * GAP) / 3))); const padWidth = keySize * 3 + GAP * 2; // Dots at 0.22 of a key rather than 0.17. They are the only readout of how // much of the PIN has landed, and at the smaller ratio they were a row of // specks beneath a keypad that dwarfed them. const dotSize = Math.round(keySize * 0.22); const [pin, setPin] = useState(""); // Source of truth for the latest digits — lets callbacks fire OUTSIDE the // setPin updater (calling a parent setState inside an updater triggers // "setState while rendering"). Kept in sync with state on every render. const pinRef = useRef(pin); pinRef.current = pin; // Clear when resetKey changes. const [seenKey, setSeenKey] = useState(resetKey); if (resetKey !== seenKey) { setSeenKey(resetKey); if (pin) { pinRef.current = ""; setPin(""); } } /** * A tap you can feel. * * Deliberately fire-and-forget: the feedback is worth nothing if the digit * waits for it, and a device with no haptic engine rejects the promise rather * than throwing, which must not surface as an unhandled rejection over a * keypress. */ const tap = useCallback(() => { if (!haptics || Platform.OS === "web") { return; } void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => { // No haptic engine, or the OS declined. Nothing to say about it. }); }, [haptics]); const press = useCallback( (d: string) => { const cur = pinRef.current; if (cur.length >= length) { return; } tap(); const next = cur + d; pinRef.current = next; setPin(next); onChange?.(next); if (next.length === length) { onComplete(next); } }, [length, onChange, onComplete, tap] ); const back = useCallback(() => { if (pinRef.current.length === 0) { return; } tap(); const next = pinRef.current.slice(0, -1); pinRef.current = next; setPin(next); onChange?.(next); }, [onChange, tap]); // Physical keyboard / numpad support (web): digits type, Backspace/Delete // removes, Enter submits a full PIN. useEffect(() => { if (Platform.OS !== "web" || typeof window === "undefined") { return; } const onKey = (e: KeyboardEvent) => { if (e.key >= "0" && e.key <= "9") { e.preventDefault(); press(e.key); } else if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); back(); } else if (e.key === "Enter") { e.preventDefault(); if (pinRef.current.length === length) { onComplete(pinRef.current); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [press, back, onComplete, length]); return ( {/* dots */} {Array.from({ length }).map((_, i) => ( ))} {/* keypad */} {KEYS.map((k) => ( press(k)} /> ))} {onBiometric ? ( } accessibilityLabel="Unlock with biometrics" /> ) : ( )} press("0")} /> } accessibilityLabel="Delete" /> ); } function Key({ label, icon, size, onPress, accessibilityLabel, }: { label?: string; icon?: React.ReactNode; size: number; onPress: () => void; accessibilityLabel?: string; }) { // The resting key is a ring and a digit, so the press has to carry all the // feedback: scale dips, and the fill fades up from nothing underneath. Driven // from one shared value so the two cannot fall out of step. const pressed = useSharedValue(0); const pressStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 - pressed.value * 0.08 }], })); const fillStyle = useAnimatedStyle(() => ({ opacity: pressed.value })); return ( { pressed.value = withTiming(1, { duration: 60 }); }} onPressOut={() => { pressed.value = withTiming(0, { duration: 160 }); }} style={{ width: size, height: size, borderRadius: size / 2 }} className="items-center justify-center overflow-hidden border border-border dark:border-neutral-700" accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label ?? "key"} > {/* The pressed fill, behind the digit. A sibling with animated opacity rather than an animated backgroundColor: colour has to come from the theme's classes, and those cannot be interpolated. */} {icon ?? ( {label} )} ); } export default PinPad;