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;