diff --git a/components/molecules/ScanCameraView.tsx b/components/molecules/ScanCameraView.tsx new file mode 100644 index 0000000..291be1c --- /dev/null +++ b/components/molecules/ScanCameraView.tsx @@ -0,0 +1,63 @@ +import { Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { CameraView, type BarcodeScanningResult } from "expo-camera"; +import { useTranslation } from "react-i18next"; +import NavBar from "@/components/molecules/NavBar"; +import ScanFrame from "@/components/molecules/ScanFrame"; +import { SCANNABLE_BARCODE_TYPES } from "@/constants/scan"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import type { ScannedBarcode } from "@/types/scan"; + +type ScanCameraViewProps = { + scannedBarcode: ScannedBarcode | null; + onBarcodeScanned: (result: BarcodeScanningResult) => void; + onScanAgain: () => void; +}; + +export default function ScanCameraView({ + scannedBarcode, + onBarcodeScanned, +}: ScanCameraViewProps) { + const { t } = useTranslation(); + const { colors } = useAppTheme(); + + return ( + + + + + + + + + {!scannedBarcode ? ( + <> + + + + {t("scan.placeBarcode")} + + + + ) : null} + + + + + + + ); +} diff --git a/components/molecules/ScanFrame.tsx b/components/molecules/ScanFrame.tsx new file mode 100644 index 0000000..7fd7747 --- /dev/null +++ b/components/molecules/ScanFrame.tsx @@ -0,0 +1,31 @@ +import { View } from "react-native"; +import { useAppTheme } from "@/hooks/use-app-theme"; + +export default function ScanFrame() { + const { colors } = useAppTheme(); + + return ( + + + + + + + + ); +} diff --git a/components/molecules/ScanPermissionCard.tsx b/components/molecules/ScanPermissionCard.tsx new file mode 100644 index 0000000..da18728 --- /dev/null +++ b/components/molecules/ScanPermissionCard.tsx @@ -0,0 +1,76 @@ +import { Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useTranslation } from "react-i18next"; +import AppScreen from "@/components/layouts/AppScreen"; +import PrimaryButton from "@/components/atoms/PrimaryButton"; +import { useAppTheme } from "@/hooks/use-app-theme"; + +type ScanPermissionCardProps = { + title: string; + detail?: string; + actionLabel?: string; + onPress?: () => void; +}; + +export default function ScanPermissionCard({ + title, + detail, + actionLabel, + onPress, +}: ScanPermissionCardProps) { + const { t } = useTranslation(); + const { colors, shadows } = useAppTheme(); + + return ( + + + + + + + + {t("nav.scan")} + + + {t("scan.title")} + + + {title} + + + {detail ? ( + + {detail} + + ) : null} + + {actionLabel && onPress ? ( + + + + ) : null} + + + ); +} diff --git a/constants/scan.ts b/constants/scan.ts new file mode 100644 index 0000000..e6ea93c --- /dev/null +++ b/constants/scan.ts @@ -0,0 +1,10 @@ +import type { BarcodeType } from "expo-camera"; + +export const SCANNABLE_BARCODE_TYPES: BarcodeType[] = [ + "ean13", + "ean8", + "upc_a", + "upc_e", + "code128", + "itf14", +]; diff --git a/hooks/scan/useScanController.ts b/hooks/scan/useScanController.ts new file mode 100644 index 0000000..447ea37 --- /dev/null +++ b/hooks/scan/useScanController.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { Linking, Platform } from "react-native"; +import { type BarcodeScanningResult, useCameraPermissions } from "expo-camera"; +import { useTranslation } from "react-i18next"; +import type { ScannedBarcode, ScanPermissionState } from "@/types/scan"; + +function getPermissionState( + permission: ReturnType[0], +): ScanPermissionState { + if (!permission) { + return "checking"; + } + + if (permission.granted) { + return "granted"; + } + + return permission.canAskAgain ? "requestable" : "blocked"; +} + +export function useScanController() { + const { t } = useTranslation(); + const [permission, requestPermission] = useCameraPermissions(); + const [scannedBarcode, setScannedBarcode] = useState( + null, + ); + const [settingsError, setSettingsError] = useState(null); + const scanLockedRef = useRef(false); + const permissionState = getPermissionState(permission); + const canOpenSettings = Platform.OS !== "web"; + + useEffect(() => { + if (permission?.status === "undetermined") { + void requestPermission(); + } + }, [permission?.status, requestPermission]); + + const handleRequestPermission = () => { + setSettingsError(null); + void requestPermission(); + }; + + const handleOpenSettings = async () => { + setSettingsError(null); + + if (!canOpenSettings) { + setSettingsError(t("scan.settingsUnavailable")); + return; + } + + try { + await Linking.openSettings(); + } catch { + setSettingsError(t("scan.settingsUnavailable")); + } + }; + + const handleBarcodeScanned = (result: BarcodeScanningResult) => { + if (scanLockedRef.current) { + return; + } + + const data = result.data?.trim(); + + if (!data) { + return; + } + + scanLockedRef.current = true; + setScannedBarcode({ + data, + type: result.type, + }); + }; + + const handleScanAgain = () => { + scanLockedRef.current = false; + setScannedBarcode(null); + }; + + return { + canOpenSettings, + handleBarcodeScanned, + handleOpenSettings, + handleRequestPermission, + handleScanAgain, + permissionState, + scannedBarcode, + settingsError, + }; +} diff --git a/types/scan.ts b/types/scan.ts new file mode 100644 index 0000000..87936ba --- /dev/null +++ b/types/scan.ts @@ -0,0 +1,9 @@ +import type { BarcodeScanningResult } from "expo-camera"; + +export type ScanPermissionState = + | "checking" + | "requestable" + | "blocked" + | "granted"; + +export type ScannedBarcode = Pick;