From a36b74a1cfdda402e6c581e33d6060d4f91890f4 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 15 Jun 2026 20:54:11 +0700 Subject: [PATCH] feat(scan): add camera capture for Android with toggle between upload and live camera - Add CameraCapture component with live viewfinder via getUserMedia - Support rear/environment camera (default) with switch to front/user - Capture to JPEG 92% quality via canvas, reuse existing upload flow - Toggle between 'Unggah' (file upload) and 'Kamera' (live capture) modes - Error handling for denied/not found/not readable in Bahasa Indonesia - Add patch script for AndroidManifest CAMERA permission (gen/ is gitignored) Co-Authored-By: Claude --- apps/tauri/scripts/patch-android-manifest.sh | 20 ++ apps/web/src/components/camera-capture.tsx | 202 +++++++++++++++++++ apps/web/src/pages/scan-page.tsx | 110 +++++++--- 3 files changed, 305 insertions(+), 27 deletions(-) create mode 100755 apps/tauri/scripts/patch-android-manifest.sh create mode 100644 apps/web/src/components/camera-capture.tsx diff --git a/apps/tauri/scripts/patch-android-manifest.sh b/apps/tauri/scripts/patch-android-manifest.sh new file mode 100755 index 0000000..15de964 --- /dev/null +++ b/apps/tauri/scripts/patch-android-manifest.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Patches the generated AndroidManifest.xml to add CAMERA permission. +# Run after `tauri android init` to apply. +set -euo pipefail + +MANIFEST="gen/android/app/src/main/AndroidManifest.xml" + +if [ ! -f "$MANIFEST" ]; then + echo "ERROR: $MANIFEST not found. Run 'tauri android init' first." >&2 + exit 1 +fi + +if grep -q 'android.permission.CAMERA' "$MANIFEST"; then + echo "CAMERA permission already present in AndroidManifest.xml" + exit 0 +fi + +echo "Adding CAMERA permission to AndroidManifest.xml..." +sed -i 's||\n \n \n |' "$MANIFEST" +echo "Done." diff --git a/apps/web/src/components/camera-capture.tsx b/apps/web/src/components/camera-capture.tsx new file mode 100644 index 0000000..7f69c15 --- /dev/null +++ b/apps/web/src/components/camera-capture.tsx @@ -0,0 +1,202 @@ +import { useRef, useState, useCallback, useEffect } from "react"; +import { SwitchCamera, CameraOff, Aperture } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface CameraCaptureProps { + onCapture: (file: File) => void; + onClose: () => void; +} + +type FacingMode = "environment" | "user"; + +export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) { + const videoRef = useRef(null); + const streamRef = useRef(null); + const canvasRef = useRef(null); + const [facingMode, setFacingMode] = useState("environment"); + const [status, setStatus] = useState<"loading" | "ready" | "error" | "denied">("loading"); + const [errorMsg, setErrorMsg] = useState(""); + + const stopStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + }, []); + + const startCamera = useCallback( + async (mode: FacingMode) => { + stopStream(); + setStatus("loading"); + setErrorMsg(""); + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: mode, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }); + + streamRef.current = stream; + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + setStatus("ready"); + } catch (err: unknown) { + const e = err as DOMException; + if (e.name === "NotAllowedError" || e.name === "PermissionDeniedError") { + setStatus("denied"); + setErrorMsg("Izin kamera ditolak. Buka pengaturan untuk mengizinkan akses kamera."); + } else if (e.name === "NotFoundError") { + setStatus("error"); + setErrorMsg("Kamera tidak ditemukan pada perangkat ini."); + } else if (e.name === "NotReadableError") { + setStatus("error"); + setErrorMsg("Kamera sedang digunakan oleh aplikasi lain."); + } else { + setStatus("error"); + setErrorMsg(`Gagal mengakses kamera: ${e.message}`); + } + } + }, + [stopStream], + ); + + // Start camera on mount + useEffect(() => { + startCamera(facingMode); + return () => stopStream(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const toggleFacing = () => { + const next = facingMode === "environment" ? "user" : "environment"; + setFacingMode(next); + startCamera(next); + }; + + const handleCapture = () => { + const video = videoRef.current; + const canvas = canvasRef.current; + if (!video || !canvas) return; + + const vw = video.videoWidth; + const vh = video.videoHeight; + canvas.width = vw; + canvas.height = vh; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.drawImage(video, 0, 0, vw, vh); + canvas.toBlob( + (blob) => { + if (!blob) return; + const file = new File([blob], `camera-${Date.now()}.jpg`, { + type: "image/jpeg", + }); + stopStream(); + onCapture(file); + }, + "image/jpeg", + 0.92, + ); + }; + + return ( +
+ {/* Viewfinder */} +
+ {status === "loading" && ( +
+
+
+ Membuka kamera... +
+
+ )} + + {(status === "error" || status === "denied") && ( +
+
+ +

{errorMsg}

+ +
+
+ )} + +