diff --git a/app/access-scanner.tsx b/app/access-scanner.tsx index c0ca9b6..1e97bd3 100644 --- a/app/access-scanner.tsx +++ b/app/access-scanner.tsx @@ -1,5 +1,5 @@ -import { View, Text, ActivityIndicator, AccessibilityInfo } from "react-native"; -import React, { useRef, useState } from "react"; +import { View, Text, ActivityIndicator, AccessibilityInfo, Animated, Platform } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; import { useRouter } from "expo-router"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult } from "expo-camera"; @@ -8,7 +8,7 @@ import { AppHeader } from "../src/components/AppHeader"; import { Button } from "../src/components/Button"; import { Card } from "../src/components/Card"; import { verifyAndParseAccessQrPayload } from "../src/features/access/verifyQrPayload"; -import { describeQrSignatureError, QrSignatureError } from "../src/features/access/qrSignature"; +import { describeQrSignatureError, QrSignatureError, QR_SIGNATURE_ERROR_CODES } from "../src/features/access/qrSignature"; import { ACCESS_QR_TYPE, ACCESS_QR_VERSION, @@ -49,11 +49,30 @@ const TEST_QR_PAYLOADS = { malformedJson: "{ this is not a GuildPass QR payload", }; +const AUTO_RESET_DELAY_MS = 3000; + const isUntrustedPayloadError = (error: QrPayloadError) => error.code === QR_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE || error.code === QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_VERSION || error.code === QR_PAYLOAD_ERROR_CODES.INVALID_KID; +/** Determines whether a scan error should auto-reset or require manual dismissal. */ +const isRecoverableError = (error: unknown): boolean => { + if (error instanceof QrSignatureError) { + // KEY_REGISTRY_EXPIRED and PUBLIC_KEY_UNAVAILABLE are network-recoverable; + // all other signature errors indicate untrusted/malicious QR codes. + return ( + error.code === QR_SIGNATURE_ERROR_CODES.KEY_REGISTRY_EXPIRED || + error.code === QR_SIGNATURE_ERROR_CODES.PUBLIC_KEY_UNAVAILABLE + ); + } + if (error instanceof QrPayloadError) { + return !isUntrustedPayloadError(error); + } + // Unexpected errors are treated as recoverable (likely transient). + return true; +}; + export default function AccessScanner() { const router = useRouter(); const [permission, requestPermission] = useCameraPermissions(); @@ -63,9 +82,53 @@ export default function AccessScanner() { const [isProcessingScan, setIsProcessingScan] = useState(false); const [verificationSuccess, setVerificationSuccess] = useState(false); const scanInProgressRef = useRef(false); + + // Animation values + const successScale = useRef(new Animated.Value(0)).current; + const successOpacity = useRef(new Animated.Value(0)).current; + const checkmarkScale = useRef(new Animated.Value(0)).current; + const errorSlide = useRef(new Animated.Value(20)).current; + const errorOpacity = useRef(new Animated.Value(0)).current; + const entries = useAccessHistoryStore((state) => state.entries); const clearHistory = useAccessHistoryStore((state) => state.clearHistory); + // Auto-reset recoverable errors after a short delay + useEffect(() => { + if (!scanError || scanError.isUntrusted) { + return; + } + + const timer = setTimeout(() => { + scanInProgressRef.current = false; + setScanError(null); + setIsProcessingScan(false); + errorSlide.setValue(20); + errorOpacity.setValue(0); + }, AUTO_RESET_DELAY_MS); + + return () => clearTimeout(timer); + }, [scanError]); + + // Animate error card entrance + useEffect(() => { + if (scanError) { + Animated.parallel([ + Animated.spring(errorSlide, { + toValue: 0, + friction: 8, + tension: 100, + useNativeDriver: true, + }), + Animated.timing(errorOpacity, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + ]).start(); + } + }, [scanError, errorSlide, errorOpacity]); + const handleScanData = async (data: string) => { if (scanInProgressRef.current) { return; @@ -83,20 +146,51 @@ export default function AccessScanner() { setVerificationSuccess(true); AccessibilityInfo.announceForAccessibility("QR code accepted. Opening access check."); - setTimeout(() => { + // Animate success: scale in the card, then scale the checkmark + successScale.setValue(0); + successOpacity.setValue(0); + checkmarkScale.setValue(0); + + Animated.sequence([ + Animated.parallel([ + Animated.spring(successScale, { + toValue: 1, + friction: 6, + tension: 80, + useNativeDriver: true, + }), + Animated.timing(successOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]), + Animated.spring(checkmarkScale, { + toValue: 1, + friction: 4, + tension: 120, + useNativeDriver: true, + }), + Animated.timing(successOpacity, { + toValue: 0, + duration: 400, + delay: 800, + useNativeDriver: true, + }), + ]).start(() => { setVerificationSuccess(false); router.replace({ pathname: "/access-check", params: { qrPayload: data } }); - }, 1500); + }); } catch (error) { let errorMessage = "Unable to read QR payload."; let isUntrusted = false; if (error instanceof QrSignatureError) { errorMessage = describeQrSignatureError(error.code); - isUntrusted = true; + isUntrusted = !isRecoverableError(error); } else if (error instanceof QrPayloadError) { errorMessage = describeQrPayloadError(error.code); - isUntrusted = isUntrustedPayloadError(error); + isUntrusted = !isRecoverableError(error); } setScanError({ message: errorMessage, isUntrusted }); @@ -114,6 +208,15 @@ export default function AccessScanner() { scanInProgressRef.current = false; setScanError(null); setIsProcessingScan(false); + errorSlide.setValue(20); + errorOpacity.setValue(0); + }; + + const getPermissionInstructions = (): string => { + if (Platform.OS === "ios") { + return "Open Settings > Privacy & Security > Camera, and enable camera access for GuildPass."; + } + return "Open Settings > Apps > GuildPass > Permissions, and enable camera access."; }; if (!permission) { @@ -136,7 +239,7 @@ export default function AccessScanner() { const permissionMessage = permissionDenied ? permission.canAskAgain ? "Camera permission was denied. Please allow camera access to scan QR codes." - : "Camera permission was permanently denied. Open Settings to enable camera access for GuildPass to scan QR codes." + : `Camera permission was permanently denied. ${getPermissionInstructions()}` : "GuildPass needs camera permission to scan access check QR codes."; return ( @@ -165,7 +268,6 @@ export default function AccessScanner() { {permission.canAskAgain ? (