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;