diff --git a/package.json b/package.json index dd54c9d..363829c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.0.69", + "version": "1.0.70", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 179f30a..2bbd51b 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -2,15 +2,17 @@ * CameraCapture — unified camera for browser + native Capacitor app * * On native (Android / iOS via Capacitor): - * → Uses @capacitor/camera which calls the OS native camera directly. - * No WebView permission popup, no getUserMedia, no dialog needed. - * The user sees the full native camera UI, then returns here with the photo. + * → Uses the Camera plugin (via registerPlugin from @capacitor/core) which + * calls the OS native camera directly. No WebView permission popup, no + * getUserMedia, no dialog needed. The user sees the full native camera UI + * (including its own preview + retake), then returns here with the photo. * * On browser (desktop / mobile web): * → Falls back to getUserMedia stream inside this dialog component. */ import { useState, useRef, useCallback, useEffect } from 'react'; +import { Capacitor, registerPlugin } from '@capacitor/core'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -30,48 +32,56 @@ import { } from 'lucide-react'; // ─── Capacitor detection ──────────────────────────────────────────────────── -// We dynamically import Capacitor to avoid breaking the web build. -// On native this module exists; on web it throws/returns isNativePlatform=false. +// `@capacitor/core` is bundled and always installed, so we import it directly. +// `isNativePlatform()` is web-safe (returns false in a browser). -async function isNative(): Promise { +function isNative(): boolean { try { - const { Capacitor } = await import('@capacitor/core'); return Capacitor.isNativePlatform(); } catch { return false; } } -// The Camera plugin is provided by Capacitor at runtime on native. We access it -// through the runtime-injected `window.Capacitor.Plugins.Camera` rather than a -// static/dynamic `import('@capacitor/camera')`, so the bundle contains no bare -// `@capacitor/camera` specifier (which a native WebView can't resolve, and which -// isn't installed on every build machine). CameraResultType/CameraSource are -// plain string enums in the plugin, so we pass their string values directly. +// The Camera plugin is accessed via Capacitor's official `registerPlugin` +// factory (from the bundled @capacitor/core), NOT via a bare +// `import('@capacitor/camera')`. registerPlugin returns a proxy that dispatches +// to the native Camera implementation over the Capacitor bridge, so: +// • the bundle contains no `@capacitor/camera` specifier (native WebViews +// can't resolve it, and it isn't installed on every build machine), and +// • the plugin is reachable even though nothing ever imports the JS package +// to trigger its side-effect self-registration (that was the bug that made +// Quick Snap open the gallery instead of the camera). +// CameraResultType/CameraSource are plain string enums, so we pass their string +// values directly. interface CapacitorCameraPlugin { getPhoto(options: { resultType: string; source: string; quality: number; correctOrientation: boolean; + saveToGallery?: boolean; + allowEditing?: boolean; }): Promise<{ dataUrl?: string }>; } -function getNativeCamera(): CapacitorCameraPlugin | null { - const cap = (window as unknown as { - Capacitor?: { Plugins?: { Camera?: CapacitorCameraPlugin } }; - }).Capacitor; - return cap?.Plugins?.Camera ?? null; +let cameraPlugin: CapacitorCameraPlugin | null = null; +function getNativeCamera(): CapacitorCameraPlugin { + if (!cameraPlugin) { + cameraPlugin = registerPlugin('Camera'); + } + return cameraPlugin; } async function captureNative(): Promise { const Camera = getNativeCamera(); - if (!Camera) throw new Error('Camera plugin unavailable'); const photo = await Camera.getPhoto({ resultType: 'dataUrl', // CameraResultType.DataUrl source: 'CAMERA', // CameraSource.Camera quality: 90, correctOrientation: true, + saveToGallery: false, + allowEditing: false, }); if (!photo.dataUrl) return null; const res = await fetch(photo.dataUrl); @@ -179,18 +189,13 @@ export function CameraCapture({ return; } - let cancelled = false; - isNative().then((native) => { - if (cancelled) return; - if (native) { - handleNativeCapture(); - } else { - startCamera(); - } - }); + if (isNative()) { + handleNativeCapture(); + } else { + startCamera(); + } return () => { - cancelled = true; stopCamera(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/lib/quickCapture.ts b/src/lib/quickCapture.ts index 16a2a50..095cbfc 100644 --- a/src/lib/quickCapture.ts +++ b/src/lib/quickCapture.ts @@ -1,51 +1,74 @@ /** * Quick Snap capture helper — platform-aware, camera-first. * - * NATIVE (Capacitor APK): uses the runtime-injected Camera plugin - * (window.Capacitor.Plugins.Camera) with source:'CAMERA', which forces the - * real OS camera and ships its own preview + retake UI. We read the plugin - * off the runtime object rather than importing '@capacitor/camera', so no - * bare specifier ends up in the bundle (that was the white-screen cause). + * NATIVE (Capacitor APK): uses Capacitor's official `registerPlugin('Camera')` + * from `@capacitor/core` (which is bundled and always installed). This returns + * a plugin PROXY that dispatches straight to the natively-registered Camera + * implementation over the Capacitor bridge — it does NOT require the JS + * `@capacitor/camera` package to have loaded and run its side-effect + * registration first. That distinction is the whole fix: + * + * Before, this helper read `window.Capacitor.Plugins.Camera`, which is only + * populated once something imports '@capacitor/camera'. Because we + * deliberately never import that package (to avoid the old white-screen + * crash), the plugin was never registered, `Plugins.Camera` was undefined, + * `isNativeCapture()` returned false, and Quick Snap fell through to the web + * file input → the GALLERY opened instead of the camera. + * + * `registerPlugin` sidesteps all of that: `@capacitor/core` guarantees the + * proxy, and the native side provides the real implementation. No bare + * `@capacitor/camera` specifier ends up in the bundle. * * WEB: there is no reliable way to force the camera from JS, so the caller - * falls back to a file input with capture="environment" (a hint the browser - * may honor). This module only handles the native path; isNativeCapture() - * tells the caller which route to take. + * falls back to a file input with capture="environment". This module only + * handles the native path; isNativeCapture() tells the caller which route to + * take. */ +import { Capacitor, registerPlugin } from '@capacitor/core'; + interface CapacitorCameraPlugin { getPhoto(options: { resultType: string; source: string; quality: number; correctOrientation: boolean; + saveToGallery?: boolean; + allowEditing?: boolean; }): Promise<{ dataUrl?: string }>; } -function getNativeCamera(): CapacitorCameraPlugin | null { - const cap = (window as unknown as { - Capacitor?: { - isNativePlatform?: () => boolean; - Plugins?: { Camera?: CapacitorCameraPlugin }; - }; - }).Capacitor; - if (!cap?.isNativePlatform?.()) return null; - return cap.Plugins?.Camera ?? null; +// Lazily create the plugin proxy once. `registerPlugin` is a pure factory — it +// just builds a proxy object and never touches native APIs at call time, so +// this is safe on web too (the proxy simply has no native impl there). +let cameraPlugin: CapacitorCameraPlugin | null = null; +function getCameraPlugin(): CapacitorCameraPlugin { + if (!cameraPlugin) { + cameraPlugin = registerPlugin('Camera'); + } + return cameraPlugin; } -/** True when the native Camera plugin is available (i.e. inside the app). */ +/** + * True when we're running inside a native Capacitor shell, where the Camera + * plugin proxy can reach a real native implementation. On web this is false and + * the caller uses the file-input fallback. + */ export function isNativeCapture(): boolean { - return getNativeCamera() !== null; + try { + return Capacitor.isNativePlatform(); + } catch { + return false; + } } /** * Open the native camera and return the captured photo as a File. - * Returns null if the user cancels. Throws if the plugin is unavailable or - * errors (caller should fall back to the web file input). + * Returns null if the user cancels. Throws if the plugin errors for any other + * reason (caller should fall back to the web file input). */ export async function captureFromNativeCamera(): Promise { - const Camera = getNativeCamera(); - if (!Camera) throw new Error('Native camera unavailable'); + const Camera = getCameraPlugin(); let photo: { dataUrl?: string }; try { @@ -54,10 +77,12 @@ export async function captureFromNativeCamera(): Promise { source: 'CAMERA', // CameraSource.Camera — forces the camera, not the gallery quality: 90, correctOrientation: true, + saveToGallery: false, + allowEditing: false, }); } catch (err) { - // Capacitor throws with a "cancel" message when the user backs out — treat - // that as a benign null rather than an error. + // Capacitor throws with a "cancel"/"cancelled" message when the user backs + // out — treat that as a benign null rather than an error. const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); if (msg.includes('cancel')) return null; throw err; diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index a56257e..68155ea 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -770,9 +770,24 @@ export default function MapPage() { try { const file = await captureFromNativeCamera(); if (file) handleQuickCameraCapture(file); - return; // handled natively + return; // handled natively (user cancel returns null → do nothing) } catch (err) { - console.warn('[QuickSnap] native camera failed, falling back to file input:', err); + const msg = err instanceof Error ? err.message : String(err); + console.warn('[QuickSnap] native camera failed:', msg); + // A permission error means the OS blocked the camera. Don't silently + // open the gallery — that's confusing and looks like the bug. Tell the + // user how to fix it and stop here. + if (/permission|denied|not authorized|unavailable/i.test(msg)) { + toast({ + title: 'Camera permission needed', + description: + 'Allow camera access for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then try again.', + variant: 'destructive', + }); + return; + } + // Any other native error → fall back to the photo picker so the user + // can still add a spot. toast({ title: 'Camera unavailable', description: 'Opening the photo picker instead.',